Plays a Windows sound when Claude finishes, and when it asks a question or needs permission.

ชุด mod สำหรับ Claude Code บน Windows
| Mod | ทำอะไร |
|---|---|
statusline/statusline.js | Status bar 2 บรรทัด: ชื่อโปรเจค, git branch, แถบ context (ไล่สีเขียว→แดง), จำนวน token, ชื่อโมเดล และแถบเรทลิมิต Session (5 ชม.) / Week พร้อมเวลารีเซ็ต (ภาษาไทย) |
mods/clipboard-image-paste | วางรูปจาก Win+V ลงแชทได้ทันทีโดยไม่ต้องกด Alt+V และมีคำสั่ง /pasteimg ให้วางรูปจาก clipboard ซ้ำ |
mods/done-and-ask-sounds | มีเสียงเตือนตอน Claude ตอบเสร็จ และตอนถามคำถามหรือขอ permission (เงียบถ้ากำลังดูหน้าต่าง terminal อยู่) |
mods/codex-imagegen | เพิ่ม tool generate_image ให้ Claude สั่งสร้างรูปผ่าน Codex CLI (ChatGPT) ในเครื่อง (ไม่บังคับ) |
bypass/claude.cmd | พิมพ์ claude --bypass ได้เลย (แปลงเป็น --dangerously-skip-permissions) ใช้ได้ทั้งใน cmd, PowerShell และช่อง address bar ของ File Explorer |
%USERPROFILE%\.local\bin\claude.exe)codex-imagegengit clone https://github.com/bngxyz/claude-mods.git
cd claude-mods
powershell -ExecutionPolicy Bypass -File install.ps1
ถ้าจะใช้ codex-imagegen ด้วย ให้ใส่ path ของ codex.exe:
powershell -ExecutionPolicy Bypass -File install.ps1 -CodexPath "C:\Users\<you>\AppData\Local\Programs\OpenAI\Codex\bin\codex.exe"
เสร็จแล้วให้ ปิดแล้วเปิด Claude Code ใหม่ และ sign out/in หรือ restart Explorer (Task Manager → Windows Explorer → Restart) เพื่อให้ PATH ใหม่มีผล
statusline.js ไปที่ %USERPROFILE%\.claude\%USERPROFILE%\.claude\mods\%USERPROFILE%\.claude\settings.json (สำรองไฟล์เดิมเป็น settings.json.bak ก่อน)statusLine → node "<...>\statusline.js"env.CLAUDE_CODE_PLUGIN_DIRS → เพิ่ม path ของแต่ละ mod (คั่นด้วย ;)pluginConfigs.codex-imagegen.options.codexPath (เฉพาะตอนใส่ -CodexPath)claude.cmd ไปที่ %USERPROFILE%\.local\claude-wrap\ แล้วใส่โฟลเดอร์นี้ไว้หน้าสุดของ user PATHHKCU\Software\Microsoft\Windows\CurrentVersion\App Paths\claude.exe ชี้ไปที่ claude.cmd เพื่อให้ address bar ของ Explorer และ Win+R เรียกผ่าน wrapperclaude # เปิดปกติ
claude --bypass # เปิดแบบข้าม permission ทั้งหมด
ในช่อง address bar ของ File Explorer พิมพ์ claude --bypass แล้วกด Enter จะเปิด Claude Code ในโฟลเดอร์นั้นเลย
ถ้าไม่อยากเห็นหน้าต่างเตือนทุกครั้งที่เปิดแบบ bypass ให้เพิ่มใน settings.json:
{
"skipDangerousModePermissionPrompt": true
}
Remove-Item "$env:USERPROFILE\.claude\statusline.js"
Remove-Item -Recurse "$env:USERPROFILE\.claude\mods\clipboard-image-paste", "$env:USERPROFILE\.claude\mods\done-and-ask-sounds", "$env:USERPROFILE\.claude\mods\codex-imagegen"
Remove-Item -Recurse "$env:USERPROFILE\.local\claude-wrap"
Remove-Item "HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths\claude.exe"
จากนั้นลบ statusLine, env.CLAUDE_CODE_PLUGIN_DIRS และ pluginConfigs.codex-imagegen ออกจาก settings.json และลบ %USERPROFILE%\.local\claude-wrap ออกจาก user PATH (Settings → System → About → Advanced system settings → Environment Variables)
hooks/register.ts 137 lines1import type { Hook, Register } from 'claude-code'
2
3type Engine = Parameters<Hook<'turn.complete'>>[0]
4
5const PLUGIN = 'done-and-ask-sounds'
6
7// Windows' own sounds, in C:\Windows\Media.
8const SOUNDS = {
9 done: 'Windows Notify System Generic.wav',
10 ask: 'Windows Exclamation.wav',
11} as const
12
13type SoundName = keyof typeof SOUNDS
14
15// One PowerShell for the session's life with every sound loaded, so a sound
16// costs a file write instead of a process start. It waits for the trigger file,
17// which holds the name of the sound to play. It stays silent while the window
18// in front is the terminal this session runs in.
19const PLAYER_SCRIPT = `
20$ErrorActionPreference = 'Stop'
21Add-Type -Namespace Win -Name Fg -MemberDefinition '[System.Runtime.InteropServices.DllImport("user32.dll")] public static extern System.IntPtr GetForegroundWindow(); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(System.IntPtr hWnd, out uint pid); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern System.IntPtr GetAncestor(System.IntPtr hWnd, uint flags);'
22Add-Type -Namespace Win -Name Con -MemberDefinition '[System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern bool AttachConsole(uint pid); [System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern bool FreeConsole(); [System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow();'
23# The windows of the terminal this session runs in. Walk the ancestors of this
24# process (Explorer, which launches terminals, aside): one that owns a window is
25# a Windows Terminal or editor window; a classic console window belongs to the
26# console the ancestors share, which AttachConsole reaches.
27$windows = @()
28$chain = @()
29$cur = $PID
30for ($depth = 0; $depth -lt 32; $depth++) {
31 $info = Get-CimInstance Win32_Process -Filter "ProcessId=$cur"
32 if ($null -eq $info -or $info.ParentProcessId -eq 0) { break }
33 $cur = [int]$info.ParentProcessId
34 $parent = Get-Process -Id $cur -ErrorAction SilentlyContinue
35 if ($null -eq $parent -or $parent.ProcessName -eq 'explorer') { break }
36 $chain += $cur
37 if ($parent.MainWindowHandle -ne 0) {
38 $windows += [pscustomobject]@{ Pid = [uint32]$cur; Handle = $parent.MainWindowHandle }
39 }
40}
41[void][Win.Con]::FreeConsole()
42foreach ($ancestor in $chain) {
43 if ([Win.Con]::AttachConsole([uint32]$ancestor)) {
44 $console = [Win.Con]::GetConsoleWindow()
45 [void][Win.Con]::FreeConsole()
46 if ($console -ne [System.IntPtr]::Zero) {
47 $windows += [pscustomobject]@{ Pid = [uint32]0; Handle = $console }
48 # Under Windows Terminal the console window is a hidden one the terminal
49 # window owns: the window in front is its root owner.
50 $owner = [Win.Fg]::GetAncestor($console, 3)
51 if ($owner -ne [System.IntPtr]::Zero -and $owner -ne $console) {
52 $windows += [pscustomobject]@{ Pid = [uint32]0; Handle = $owner }
53 }
54 }
55 }
56}
57if ($windows.Count -eq 0) { throw 'could not find the terminal window this session runs in' }
58$media = Join-Path $env:SystemRoot 'Media'
59$players = @{}
60foreach ($pair in ($env:CLAUDE_SOUNDS -split ';')) {
61 $name, $file = $pair -split '=', 2
62 $player = New-Object System.Media.SoundPlayer (Join-Path $media $file)
63 $player.Load()
64 $players[$name] = $player
65}
66$trigger = $env:SOUND_TRIGGER
67Write-Output 'ready'
68while ($true) {
69 if (Test-Path -LiteralPath $trigger) {
70 $name = ''
71 try { $name = (Get-Content -LiteralPath $trigger -Raw).Trim() } catch { Start-Sleep -Milliseconds 10; continue }
72 Remove-Item -LiteralPath $trigger -Force
73 if (-not $players.ContainsKey($name)) { throw "unknown sound: $name" }
74 # Silent while the person is looking at the terminal.
75 $foreground = [Win.Fg]::GetForegroundWindow()
76 $foregroundPid = [uint32]0
77 [void][Win.Fg]::GetWindowThreadProcessId($foreground, [ref]$foregroundPid)
78 $isLookingAtTerminal = $false
79 foreach ($window in $windows) {
80 if ($window.Handle -eq $foreground -or $window.Pid -eq $foregroundPid) { $isLookingAtTerminal = $true }
81 }
82 if (-not $isLookingAtTerminal) { $players[$name].Play() }
83 }
84 Start-Sleep -Milliseconds 20
85}
86`
87
88function triggerPath($: Engine): string {
89 return `${$.plugin.root}/.sound-trigger`
90}
91
92async function play($: Engine, name: SoundName): Promise<void> {
93 await $.fs.write(triggerPath($), name)
94}
95
96export const register: Register = on => {
97 on('session.start', async ($, e, next) => {
98 const started = await next(e)
99
100 void (async () => {
101 const player = $.process.spawn({
102 argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-Command', PLAYER_SCRIPT],
103 env: {
104 SOUND_TRIGGER: triggerPath($),
105 CLAUDE_SOUNDS: Object.entries(SOUNDS)
106 .map(([name, file]) => `${name}=${file}`)
107 .join(';'),
108 },
109 })
110
111 for await (const { stream, text } of player) {
112 if (stream === 'stderr') $.ui.toast(`${PLUGIN}: ${text.trim()}`)
113 }
114
115 $.ui.toast(`${PLUGIN}: the sound player stopped`)
116 })()
117
118 return started
119 })
120
121 // Claude has finished answering (the main loop, not a subagent's run).
122 on('turn.complete', async ($, e, next) => {
123 if (e.agentId === undefined && e.reason === 'answer') await play($, 'done')
124
125 return next(e)
126 })
127
128 // A real tool call the engine settled as "ask": a permission dialog, a
129 // question put to the person, or a plan to approve.
130 on('tool.check', async ($, e, next) => {
131 const decided = await next(e)
132 if (e.tool_use_id !== undefined && decided.decision === 'ask') await play($, 'ask')
133
134 return decided
135 }).catch(($, e, next) => next(e))
136}
137