Windows toast notifications when a long turn or command finishes, when Claude waits for permission, when a turn stalls, and when a turn fails. Inside an…

Windows 環境の Claude Code dotfiles(hooks・skills・settings)を一元管理するリポジトリ。
claude/
install.ps1 インストーラ(hooks・skills・settings を ~/.claude に展開)
settings.template.json settings.json のテンプレート
hooks/ Claude Code フック用スクリプト
skills/ Claude Code スキル
app-settings/ アプリ設定ファイルのバックアップ
tools/ 汎用 PowerShell ユーティリティ
app-settings/pwsh/Microsoft.PowerShell_profile.ps1 は基本ヘルパー・エイリアスと Update-Profile を持つローダーで、機能本体は app-settings/pwsh/profile.d/ に 分割しています(navigation / git / vs-build / integrations / ai-cli / devtools-catalog / devtools / extra-tools / windows-settings / psreadline / help)。 読み込み順は profile.d/manifest.txt が決めます。新しいファイルを足すときは profile.d/ に置いて manifest.txt に 1 行追加します。配置時はローダーと profile.d/ の両方が必要です。
ローカルの変更を反映する場合は、リポジトリから同期します。
pwsh -NoProfile -File tools/Sync-AppSettings.ps1 -WhatIf
pwsh -NoProfile -File tools/Sync-AppSettings.ps1
同期先は Windows の Documents フォルダ配下の PowerShell/ です。 実環境で編集した場合は -Direction Pull でローダーと profile.d/ を管理元へ取り込みます。 profile は現在読み込んでいるプロファイルを編集し、reload は再読み込みします。 Update-Profile は GitHub の main からローダーと profile.d/ 一式を取得し、構文検証・ 上書き確認・バックアップ後に更新するため、未公開のローカル変更は反映しません。
コマンド一覧は phelp(例: phelp git)、検索は Ctrl+G で開けます。 gl、gp、gcm、cat は標準エイリアスを置き換え、それぞれ Git log、Git push、Git commit、bat 連携として動作します。 gco の引数なし実行は git-switch と同じブランチ選択を使います。 その他の既存の短縮名も維持しています。
検証は pwsh -NoProfile -File app-settings/pwsh/tests/Invoke-AllTests.ps1 で全テストファイルを一括実行します(個別実行は各 Test-*.ps1)。
DevDeck(ntaksh42/DevDeck)と RdpManager(ntaksh42/rdp-manager)は全端末には不要なため、 Install-DevTools のカタログには入れず、専用の Install-ExtraTools で導入します。 GitHub Releases の最新インストーラを取得して実行し、導入済みで最新なら何もしません。 古い場合は更新し、実行中のアプリは終了します。
Install-ExtraTools # DevDeck と RdpManager の両方
Install-ExtraTools -Name DevDeck # 指定したものだけ
Install-ExtraTools -Name RdpManager -Yes # 確認を省略
DevDeck は NSIS 版(ユーザー単位・無人)、RdpManager は MSI 版(マシン単位のため UAC が出ます)を使います。 対象を増やすときは profile.d/extra-tools.ps1 の $script:ExtraTools に 1 行足します。
PowerShell 7 / Git for Windows 向けの共有設定は app-settings/git/.gitconfig にあります。個人情報や署名鍵、認証 helper は 公開用の設定から分離し、~/.gitconfig.local で管理します。既存の設定が ある場合は、それをローカル設定として残してから共有設定を配置します。
# 既存の ~/.gitconfig がある場合(認証・ユーザー情報もそのまま保持)
Move-Item ~/.gitconfig ~/.gitconfig.local
Copy-Item app-settings/git/.gitconfig ~/.gitconfig
# Git を初めて設定する場合は、上の Move-Item の代わりにこちらを実行
Copy-Item app-settings/git/.gitconfig.local.example ~/.gitconfig.local
notepad ~/.gitconfig.local
delta が未導入の場合は Install-DevTools で導入できます。設定後は git config --global --list で読み込み結果を確認してください。
claude/settings.template.json の statusLine は npx -y ccstatusline@latest を呼び出します。ccstatusline はレイアウト設定を ~/.config/ccstatusline/settings.json から読み込みます。この設定は app-settings/ccstatusline/settings.json を管理元として Install-DevTools (remote-config バックエンド)が配置・更新します。
Install-DevTools
ccstatusline はTUI上での編集で設定ファイル自身を書き換えるため、 Install-DevTools は配置済みの設定と管理元の内容が異なる場合、差分を表示した 上で上書き可否を確認します(手元での編集を誤って消さないよう、この確認は -Force を付けても省略されません)。上書き確認も含めてすべての確認に「はい」と 答える場合は Install-DevTools -Yes を使います(差分表示とバックアップは行われます)。
Install-DevTools は ~/.agents/skills/ にだけあるスキルを ~/.claude/skills/ へ移し、 元の場所にシンボリックリンクを作ります。同名の既存スキルと SKILL.md のないディレクトリは変更しません。
配置後は Claude Code を再起動すると、モデル・コンテキスト使用率・git ブランチ・ セッション使用量などの構成が反映されます。
Install-DevTools は claude/mods/ の Mod(auto-session-title、toast-notify など)も導入します。 dotfiles-mods マーケットプレイスが未登録なら GitHub の ntaksh42/dotfiles から登録し、 その後 claude plugin install を実行します。claude/install.ps1 がクローン先のパスで 登録済みの場合は、そのマーケットプレイスをそのまま使います。GitHub から登録する場合は、 Mod が main に取り込まれている必要があります。claude が PATH に無いとスキップされます。
現在は一時的に無効化中です。 Install-DevTools は Codex ステータスラインを インストールせず、導入済みの環境では %LOCALAPPDATA%\CodexStatusline\codex-wt.ps1 を codex-wt.ps1.disabled に退避します(codex は通常の Codex CLI を起動します)。 再び有効にするには、.disabled を元の名前に戻し、Install-DevTools のカタログと Disable-CodexStatusline の呼び出しを元に戻します。
有効だった頃は、下側 18% のペインにモデル、推論強度、コンテキスト、git 状態、 セッション/週次の使用量を表示していました。tools/Install-CodexStatusline.ps1 と app-settings/codex-statusline/ は再開用に残してあります。
Install-DevTools は Crit 本体と Codex 連携もインストールします。インストール時に ~/.crit.config.json の既存項目を維持しつつ、社内利用向けの安全設定として ローカルホスト限定、更新確認の無効化、Share の無効化、agent_cmd の無効化を設定します。
{
"host": "127.0.0.1",
"no_update_check": true,
"share_url": "",
"share_targets": [],
"agent_cmd": ""
}
git clone https://github.com/ntaksh42/env.git
cd env
powershell.exe -ExecutionPolicy Bypass -File claude\install.ps1
インストーラが行うこと:
claude/hooks/*.ps1 を ~/.claude/hooks/ にコピーclaude/skills/ を ~/.claude/skills/ にコピーclaude/agents/*.md を ~/.claude/agents/ にコピーsettings.template.json からパスを解決して ~/.claude/settings.json を生成.HOOK メタデータを読み取り、settings.json に自動登録設定を反映するために Claude Code を再起動してください。
%LOCALAPPDATA%\Microsoft\WinGet\Links\ に以下のツールをインストール済みです。
jq v1.8.1: JSON プロセッサ。API レスポンスや設定ファイルの前処理でトークン消費を大きく抑えられます。rg (ripgrep) v15.1.0: .gitignore を自動除外する高速 grep。--json 出力に対応し、多くの AI エージェントと相性が良いです。yq v4.53.2: YAML / TOML / XML プロセッサ。jq 風構文で K8s や CI 設定を処理できます。各フックスクリプトは先頭に .HOOK メタデータブロックを持ちます:
<#
.HOOK
{
"event": "PostToolUse",
"matcher": "Task",
"async": true
}
#>
install.ps1 がこのブロックを解析して settings.json の hooks セクションに自動登録します。 新しいフックを追加する場合は、スクリプト先頭にこのブロックを含めるだけで自動的に反映されます。
# 複数 git リポジトリを一括 pull
powershell.exe -File tools\Update-GitRepositories.ps1 -Path "C:\Projects"
# app-settings/ の設定ファイルと実環境の配置先を同期
pwsh -File tools\Sync-AppSettings.ps1 # repo -> 実環境 (既定)
pwsh -File tools\Sync-AppSettings.ps1 -Direction Pull # 実環境 -> repo
# Windows 設定(レジストリ)を適用 (pwsh プロファイル経由)。HKLM 項目は管理者権限が必要
Set-WindowsSettings -Check # 差分のみ表示
Set-WindowsSettings # 適用
Install-DevTools は Orca 本体も winget(StablyAI.Orca)でインストールします。 導入済みの場合はスキップします。
Orca の外観・エディタ・ターミナル設定は app-settings/orca/settings.json に保存します。 python tools/Export-OrcaSettings.py で現在の設定を取り込めます。認証情報や作業履歴は 対象外です。JSON の自動読み込みには対応していないため、復元方法と対象範囲は app-settings/orca/README.md を参照してください。
PowerToys の設定(FancyZones のレイアウト・ホットキーを含む)は app-settings/powertoys/ に保存します。対象は PowerToys 公式の Back up & restore と 同じ設定ファイルです。ユーザー名入りのパスを含む Workspaces\workspaces.json と NewPlus\settings.json は公開リポジトリに載せないため対象外です。
pwsh -File tools\Sync-PowerToysSettings.ps1 -Direction Pull # 実環境 -> repo(バックアップ)
pwsh -File tools\Sync-PowerToysSettings.ps1 -WhatIf # 復元の対象だけ確認
pwsh -File tools\Sync-PowerToysSettings.ps1 # repo -> 実環境(復元)
バックアップ(Pull)は app-settings/powertoys/manifest.txt も更新します。 新しい PC では Install-DevTools が PowerToys 本体(winget)に続いて設定を GitHub の main から配置します(PowerToys settings)。FancyZones\custom-layouts.json が未作成の環境にだけ 一度実行し、既存ファイルが異なる場合は .backup.<日時> を残します。設定を取り直すときは 上の Sync-PowerToysSettings.ps1 を使います。
復元は PowerToys を停止してコピーし、起動していた場合は再起動します。 FancyZones のモニターごとの適用レイアウト(applied-layouts.json)は対象外なので、 復元後に各モニターへレイアウトを選び直してください。
hooks/register.ts 167 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Windows PowerShell 5.1 の AppUserModelID。pwsh 7 は WinRT を読めないため powershell.exe を使う
4const APP_ID = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'
5// タイトル・本文は環境変数で渡し、スクリプト側で XML エスケープする(引数経由の注入を避ける)
6// rdpmanager で RDP 接続中なら仮想チャネル CCNOTIF でクライアント側へ送り、届かなければローカルにトーストを出す
7// (CCNOTIF の仕様は ntaksh42/rdp-manager の docs/remote-notifications.md)
8const SCRIPT = [
9 "$ErrorActionPreference = 'Stop'",
10 '$sent = $false',
11 'try { ' + [
12 'Add-Type -Namespace ToastNotify -Name Wts -MemberDefinition \'[DllImport("wtsapi32.dll", SetLastError = true, CharSet = CharSet.Ansi)] public static extern IntPtr WTSVirtualChannelOpen(IntPtr hServer, int sessionId, string name); [DllImport("wtsapi32.dll", SetLastError = true)] public static extern bool WTSVirtualChannelWrite(IntPtr h, byte[] buffer, int length, out int written); [DllImport("wtsapi32.dll")] public static extern bool WTSVirtualChannelClose(IntPtr h);\'',
13 '$m = if ($env:TOAST_NOTIFY_SESSION) { $env:TOAST_NOTIFY_BODY + [Environment]::NewLine + $env:TOAST_NOTIFY_SESSION } else { $env:TOAST_NOTIFY_BODY }',
14 '$j = @{ title = $env:TOAST_NOTIFY_TITLE; message = $m; level = $env:TOAST_NOTIFY_LEVEL } | ConvertTo-Json -Compress',
15 '$p = [Text.Encoding]::ASCII.GetBytes([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($j)))',
16 // 静的チャネルは 1 チャンク 1600 バイトを超えると分割され、受信側で捨てられる
17 '$h = if ($p.Length -le 1500) { [ToastNotify.Wts]::WTSVirtualChannelOpen([IntPtr]::Zero, -1, \'CCNOTIF\') } else { [IntPtr]::Zero }',
18 'if ($h -ne [IntPtr]::Zero) { try { $w = 0; $sent = [ToastNotify.Wts]::WTSVirtualChannelWrite($h, $p, $p.Length, [ref]$w) -and $w -eq $p.Length } finally { [void][ToastNotify.Wts]::WTSVirtualChannelClose($h) } }',
19 ].join('; ') + ' } catch { }',
20 'if ($sent) { exit 0 }',
21 '[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]',
22 '[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]',
23 '$t = [Security.SecurityElement]::Escape($env:TOAST_NOTIFY_TITLE)',
24 '$b = [Security.SecurityElement]::Escape($env:TOAST_NOTIFY_BODY)',
25 '$s = [Security.SecurityElement]::Escape($env:TOAST_NOTIFY_SESSION)',
26 "$a = if ($s) { '<text placement=''attribution''>' + $s + '</text>' } else { '' }",
27 '$x = New-Object Windows.Data.Xml.Dom.XmlDocument',
28 "$x.LoadXml('<toast><visual><binding template=''ToastGeneric''><text>' + $t + '</text><text>' + $b + '</text>' + $a + '</binding></visual></toast>')",
29 '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($env:TOAST_NOTIFY_APP).Show([Windows.UI.Notifications.ToastNotification]::new($x))',
30].join('; ')
31const MAX_BODY = 200
32// 停滞判定の間隔。活動の記録は同期的にフラグを立てるだけにし、時刻はここで読む
33const STALL_TICK_MS = 15_000
34
35// リロードで初期化されるため register で設定し直す
36const cfg = { minTurnMs: 30_000, minCommandMs: 60_000, stallMs: 300_000 }
37// メインのターン中に、最後に進捗(モデル出力・ツール呼び出し)があった時刻
38const stall = { active: false, last: 0, dirty: false, notified: false }
39const touch = () => { stall.dirty = true }
40// セッション名。/rename や auto-session-title の命名はプロンプト送信・セッション開始の入出力で拾う
41let sessionTitle = ''
42
43export const formatDuration = (ms: number) => {
44 const s = Math.round(ms / 1000)
45 return s < 60 ? `${s}秒` : `${Math.floor(s / 60)}分${s % 60 ? `${s % 60}秒` : ''}`
46}
47
48export const firstLine = (text: string) => {
49 const line = text.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
50 return line.length > MAX_BODY ? `${line.slice(0, MAX_BODY - 1)}…` : line
51}
52
53const project = async ($: EngineInterface) => {
54 const cwd = await $.session.cwd()
55 return cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd
56}
57
58type Level = 'info' | 'warn'
59
60async function toast($: EngineInterface, title: string, body: string, level: Level) {
61 const ran = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', SCRIPT], {
62 env: { TOAST_NOTIFY_TITLE: `${title} - ${await project($)}`, TOAST_NOTIFY_BODY: body, TOAST_NOTIFY_SESSION: sessionTitle, TOAST_NOTIFY_LEVEL: level, TOAST_NOTIFY_APP: APP_ID },
63 timeoutMs: 15_000,
64 })
65 if (ran.exitCode !== 0) await $.ui.log(`toast-notify: ${ran.stderr.trim().split('\n')[0]}`, { to: 'debug' })
66}
67
68// 通知の失敗でフックを止めない。表示を待たずに戻る
69const notify = ($: EngineInterface, title: string, body: string, level: Level = 'info') => {
70 toast($, title, body, level).catch(error => $.ui.log(`toast-notify: ${String(error)}`, { to: 'debug' }))
71}
72
73export const register: Register = (on, options) => {
74 if (options.enabled === false) return
75 cfg.minTurnMs = Math.max(0, Number(options.minTurnSeconds ?? 30)) * 1000
76 cfg.minCommandMs = Math.max(0, Number(options.minCommandSeconds ?? 60)) * 1000
77 cfg.stallMs = Math.max(0, Number(options.stallSeconds ?? 300)) * 1000
78
79 if (cfg.stallMs > 0) {
80 on('session.start', async ($, e, next) => {
81 const started = await next(e)
82 $.clock.every(STALL_TICK_MS, () => {
83 $.clock.now().then(now => {
84 if (!stall.active) return
85 if (stall.dirty) Object.assign(stall, { last: now, dirty: false, notified: false })
86 else if (!stall.notified && now - stall.last >= cfg.stallMs) {
87 stall.notified = true
88 notify($, 'Claude: 停止の可能性', `${formatDuration(cfg.stallMs)}間進捗がありません`, 'warn')
89 }
90 }, error => $.ui.log(`toast-notify: ${String(error)}`, { to: 'debug' }))
91 })
92 return started
93 })
94
95 on('turn.start', async ($, e, next) => {
96 Object.assign(stall, { active: true, last: await $.clock.now(), dirty: false, notified: false })
97 return next(e)
98 })
99
100 // サブエージェントの出力も進捗とみなす
101 on('turn.step', async function* ($, e, next) {
102 const stream = next(e)
103 for await (const chunk of stream) {
104 touch()
105 yield chunk
106 }
107 return await stream.result
108 })
109 }
110
111 on('turn.complete', async ($, e, next) => {
112 const done = await next(e)
113 if (e.agentId === undefined) stall.active = false
114 if (e.agentId !== undefined || e.isAborted) return done
115 if (e.reason === 'error' || e.reason === 'refusal') {
116 const detail = e.reason === 'refusal' ? e.refusal.explanation ?? '' : firstLine(e.answer)
117 notify($, 'Claude: エラーで停止', detail || (e.reason === 'refusal' ? 'リクエストが拒否されました' : 'API エラー'), 'warn')
118 } else if (e.durationMs >= cfg.minTurnMs) {
119 notify($, `Claude: 完了 (${formatDuration(e.durationMs)})`, firstLine(e.answer) || '応答が完了しました')
120 }
121 return done
122 })
123
124 on('classic.SessionStart', async ($, e, next) => {
125 const ran = await next(e)
126 sessionTitle = ran.sessionTitle ?? e.session_title ?? sessionTitle
127 return ran
128 })
129 on('classic.UserPromptSubmit', async ($, e, next) => {
130 const ran = await next(e)
131 sessionTitle = ran.sessionTitle ?? e.session_title ?? sessionTitle
132 return ran
133 })
134
135 // 許可・質問ダイアログ。idle_prompt はターン完了通知と重なるので出さない
136 on('classic.Notification', async ($, e, next) => {
137 const done = await next(e)
138 if (e.agent_id === undefined && e.notification_type !== 'idle_prompt' && e.notification_type !== 'auth_success') {
139 notify($, 'Claude: 入力待ち', e.message, 'warn')
140 }
141 return done
142 })
143
144 // ツールの開始・終了も進捗とみなす(実行中のまま 5 分黙ったコマンドは停滞として通知する)
145 on('tool.call', async ($, e, next) => {
146 touch()
147 try {
148 return await next(e)
149 } finally {
150 touch()
151 }
152 })
153
154 for (const tool of ['Bash', 'PowerShell'] as const) {
155 on('tool.call', { tool }, async ($, e, next) => {
156 const started = await $.clock.now()
157 const ran = await next(e)
158 const elapsed = (await $.clock.now()) - started
159 if (ran.deny === undefined && elapsed >= cfg.minCommandMs) {
160 const failed = ran.isError === true
161 notify($, `${failed ? 'コマンド失敗' : 'コマンド完了'} (${formatDuration(elapsed)})`, firstLine(e.command), failed ? 'warn' : 'info')
162 }
163 return ran
164 })
165 }
166}
167