Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@ sudo apt-get install sox # for play

Uses built-in PowerShell, no additional software needed.

For a native-Windows setup with **voice announcements that say which session
needs attention** (plus silent toast notifications), see
[`windows/README.md`](windows/README.md). It replaces the npx-tsx hooks with a
single PowerShell script and a merge-safe global installer.

### macOS

Uses built-in `afplay` with system sounds, no additional software or sound files needed.
Expand Down
52 changes: 52 additions & 0 deletions windows/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Windows: sound + voice + toast notifications

A native-Windows alternative to the TypeScript hooks that solves the
"a sound played, but *which session* was that?" problem when running several
Claude Code sessions at once.

On every `Notification`, `Stop`, and `SubagentStop` event it does, in order:

1. **Sound** - the repo's `.wav` files, so the tone tells you *what happened*
(needs attention vs. finished)
2. **Voice** - Windows' built-in text-to-speech announces *which session*:
"*job* needs your attention", "*experiment design* finished"
3. **Toast** - a silent Windows notification naming the project, as a visual
backup

No dependencies: pure PowerShell 5.1 + Node.js for the installer. No `npx`
cold-start delay.

## Install

```
git clone https://github.com/<you>/awesome-claude-code
cd awesome-claude-code
node windows/install-global.js
```

Then open `/hooks` once (or restart Claude Code). The installer merges into
`~/.claude/settings.json` (existing hooks are preserved; the file is backed up
first) and removes this repo's npx-tsx sound hooks if you had them installed,
so nothing plays twice.

## How the voice knows the session name

Resolution order (first hit wins):

1. **`/rename`** - rename a session inside Claude Code and the voice follows
automatically. (Claude Code writes the window name to
`~/.claude/sessions/<pid>.json`; names the app auto-derives are skipped.)
2. **`windows/session-names.json`** - optional manual map:
`{ "<session-id>": "spoken name" }`
3. **First prompt** - the first ~8 words of the session's first real prompt
(from `~/.claude/history.jsonl`; `!` shell and `/` slash commands are
skipped, URLs are read as "link")
4. **Folder name** - the project directory, as a last resort

Renaming a tab in Windows Terminal itself does *not* work - that name never
reaches Claude Code.

## Uninstall

Remove the three hook entries whose command contains `windows/notify.ps1`
from `~/.claude/settings.json` (or restore the `.backup` the installer made).
80 changes: 80 additions & 0 deletions windows/install-global.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env node
// Global installer for the Windows sound + voice + toast hook (windows/notify.ps1).
//
// Unlike scripts/merge-settings.js, this MERGES into existing hooks in
// ~/.claude/settings.json instead of replacing the whole hooks object, and it
// backs the file up first. It also removes this repo's npx-tsx sound hooks if
// present, so nothing plays twice.
//
// Usage: node windows/install-global.js

const fs = require('fs');
const os = require('os');
const path = require('path');

const repoRoot = path.resolve(__dirname, '..');
const scriptPath = path.join(repoRoot, 'windows', 'notify.ps1').replace(/\\/g, '/');
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');

const newCmd = `powershell -NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"`;

// Old commands superseded by notify.ps1 (drop them to avoid double sounds)
const repoFwd = repoRoot.replace(/\\/g, '/');
const oldCmds = [
`npx tsx ${repoFwd}/.claude/hooks/notification.ts --notify`,
`npx tsx ${repoFwd}/.claude/hooks/stop.ts`,
`npx tsx ${repoFwd}/.claude/hooks/stop.ts --chat`,
`npx tsx ${repoFwd}/.claude/hooks/subagent_stop.ts`,
];

const events = ['Notification', 'Stop', 'SubagentStop'];

let settings = {};
if (fs.existsSync(settingsPath)) {
const raw = fs.readFileSync(settingsPath, 'utf8').trim();
if (raw) {
try {
settings = JSON.parse(raw);
} catch (e) {
console.error(`ERROR: ${settingsPath} is not valid JSON - nothing was changed.`);
process.exit(1);
}
}
fs.copyFileSync(settingsPath, settingsPath + '.backup');
console.log(`Backed up existing settings to ${settingsPath}.backup`);
} else {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
}

settings.hooks = settings.hooks || {};

for (const event of events) {
let entries = settings.hooks[event] || [];

const before = JSON.stringify(entries);
entries = entries
.map((entry) => ({
...entry,
hooks: (entry.hooks || []).filter((h) => !oldCmds.includes(h.command)),
}))
.filter((entry) => (entry.hooks || []).length > 0);
if (JSON.stringify(entries) !== before) {
console.log(`- ${event}: removed old npx-tsx sound hook`);
}

const alreadyInstalled = entries.some((entry) =>
(entry.hooks || []).some((h) => h.command === newCmd)
);
if (!alreadyInstalled) {
entries.push({ matcher: '', hooks: [{ type: 'command', command: newCmd, timeout: 30 }] });
console.log(`+ ${event}: sound + voice + toast hook added`);
} else {
console.log(`= ${event}: already installed, skipped`);
}

settings.hooks[event] = entries;
}

fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
console.log(`\nDone. Wrote ${settingsPath}`);
console.log('Open /hooks once (or restart Claude Code) for the change to take effect.');
140 changes: 140 additions & 0 deletions windows/notify.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Claude Code hook for Windows: sound first, then a voice announces WHICH session fired.
# Reads the hook event JSON from stdin. Handles Notification, Stop, SubagentStop.
#
# Session name resolution order:
# 1) Live session registry (~/.claude/sessions/*.json) - follows /rename in Claude Code
# 2) Custom map (session-names.json next to this script)
# 3) First real prompt of the session (~/.claude/history.jsonl)
# 4) Auto-derived registry name, then project folder name
#
# Also shows a silent Windows toast as a visual backup.
# Sound files (repo root): on-agent-need-attention.wav, on-agent-complete.wav

$raw = [Console]::In.ReadToEnd()
try { $evt = $raw | ConvertFrom-Json } catch { exit 0 }

$project = 'Claude Code'
if ($evt.cwd) { $project = Split-Path $evt.cwd -Leaf }

function Get-SessionName($evt) {
# 1) Live session registry: ~/.claude/sessions/<pid>.json holds the current
# window name (follows /rename). nameSource 'derived' = auto-generated.
$registryName = $null
$registryDerived = $false
$sessDir = Join-Path $env:USERPROFILE '.claude\sessions'
if ($evt.session_id -and (Test-Path $sessDir)) {
$bestTs = -1
foreach ($f in Get-ChildItem $sessDir -Filter '*.json' -ErrorAction SilentlyContinue) {
try { $o = Get-Content $f.FullName -Raw | ConvertFrom-Json } catch { continue }
if ($o.sessionId -ne $evt.session_id -or -not $o.name) { continue }
$ts = if ($o.updatedAt) { $o.updatedAt } else { $o.startedAt }
if ($ts -gt $bestTs) {
$bestTs = $ts
$registryName = $o.name
$registryDerived = ($o.nameSource -eq 'derived')
}
}
}
if ($registryName -and -not $registryDerived) {
return ($registryName -replace '[-_]', ' ')
}

# 2) Custom names: { "<session_id>": "spoken name", ... }
$mapPath = Join-Path $PSScriptRoot 'session-names.json'
if ($evt.session_id -and (Test-Path $mapPath)) {
try {
$map = Get-Content $mapPath -Raw | ConvertFrom-Json
$custom = $map.($evt.session_id)
if ($custom) { return $custom }
} catch {}
}

# 3) First real prompt of this session from ~/.claude/history.jsonl
$hist = Join-Path $env:USERPROFILE '.claude\history.jsonl'
if ($evt.session_id -and (Test-Path $hist)) {
try {
foreach ($line in [System.IO.File]::ReadLines($hist)) {
if ($line -notlike "*$($evt.session_id)*") { continue }
$o = $line | ConvertFrom-Json
if ($o.sessionId -ne $evt.session_id -or -not $o.display) { continue }
$d = $o.display
# skip shell (!) and slash (/) commands; wait for a real prompt
if ($d -match '^[!/]') { continue }
$d = $d -replace 'https?://\S+', ' link '
$d = ($d -replace '[^a-zA-Z0-9 ]', ' ') -replace '\s+', ' '
$words = $d.Trim().Split(' ')
if ($words.Count -eq 0 -or $words[0] -eq '') { continue }
return ($words[0..([Math]::Min(7, $words.Count - 1))] -join ' ')
}
} catch {}
}

# 4) Auto-derived registry name (e.g. "projects-42"), then folder name
if ($registryName) { return ($registryName -replace '[-_]', ' ') }
return ((Split-Path $evt.cwd -Leaf) -replace '[-_]', ' ')
}

$sessionName = Get-SessionName $evt
$soundDir = Split-Path $PSScriptRoot -Parent # repo root holds the .wav files

switch ($evt.hook_event_name) {
'Notification' {
$wav = Join-Path $soundDir 'on-agent-need-attention.wav'
$speech = "$sessionName needs your attention"
$title = "$project - needs your attention"
$body = if ($evt.message) { "$sessionName : $($evt.message)" } else { $sessionName }
}
'SubagentStop' {
$wav = Join-Path $soundDir 'on-agent-complete.wav'
$speech = "Subagent finished in $sessionName"
$title = "$project - subagent finished"
$body = $sessionName
}
default {
$wav = Join-Path $soundDir 'on-agent-complete.wav'
$speech = "$sessionName finished"
$title = "$project - finished"
$body = $sessionName
}
}

# 1) Sound first (synchronous, so the voice always comes after)
if (Test-Path $wav) {
try { (New-Object Media.SoundPlayer $wav).PlaySync() } catch {}
}

# 2) Then the voice announces the session
try {
Add-Type -AssemblyName System.Speech
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
$synth.Speak($speech)
$synth.Dispose()
} catch {}

# 3) Silent toast as a visual backup
try {
$titleEsc = [System.Security.SecurityElement]::Escape($title)
$bodyEsc = [System.Security.SecurityElement]::Escape($body)

[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null

$xml = @"
<toast>
<visual>
<binding template="ToastText02">
<text id="1">$titleEsc</text>
<text id="2">$bodyEsc</text>
</binding>
</visual>
<audio silent="true"/>
</toast>
"@

$doc = New-Object Windows.Data.Xml.Dom.XmlDocument
$doc.LoadXml($xml)
$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show(
[Windows.UI.Notifications.ToastNotification]::new($doc)
)
} catch {}