Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save kazuph/d1a9738fe565625b57d76b22b9e7856e to your computer and use it in GitHub Desktop.
Save kazuph/d1a9738fe565625b57d76b22b9e7856e to your computer and use it in GitHub Desktop.
Claude Code macOS Notification Hooks - tmux統合対応の通知システム

Claude Code Hooks

stop-send-notification.js

Claudeがメッセージ生成を停止した時にmacOS通知を送るフック。

設定

~/.claude/settings.json に以下を追加:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/stop-send-notification.js"
          }
        ]
      }
    ]
  }
}

インストール

  1. stop-send-notification.js~/.claude/hooks/ に配置
  2. 実行権限を付与: chmod +x ~/.claude/hooks/stop-send-notification.js

機能

  • Claudeの最後のメッセージをmacOS通知で表示
  • tmux使用時はウィンドウとペイン番号も表示(例: Claude Code - [1-0] Initial Greeting
  • 改行を自動的にスペースに変換し、最大235文字で切り詰め
  • セキュリティ: ~/.claude/projects/ 内のファイルのみ読み取り可能

Claude Code Hooks

stop-send-notification.js

A Claude Code hook that sends macOS notifications when Claude stops generating a response.

Configuration

Add the following to ~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/stop-send-notification.js"
          }
        ]
      }
    ]
  }
}

Installation

  1. Place stop-send-notification.js in ~/.claude/hooks/
  2. Make it executable: chmod +x ~/.claude/hooks/stop-send-notification.js

Features

  • Shows Claude's last message as a macOS notification
  • Includes tmux window and pane info in title when running in tmux (e.g., Claude Code - [1-0] Initial Greeting)
  • Automatically converts newlines to spaces and truncates to 235 characters
  • Security: Only reads transcript files from ~/.claude/projects/
# Claude Code Hooks
## stop-send-notification.js
Claudeがメッセージ生成を停止した時にmacOS通知を送るフック。
### 設定
`~/.claude/settings.json` に以下を追加:
```json
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/stop-send-notification.js"
}
]
}
]
}
}
```
### インストール
1. `stop-send-notification.js` を `~/.claude/hooks/` に配置
2. 実行権限を付与: `chmod +x ~/.claude/hooks/stop-send-notification.js`
### 機能
- Claudeの最後のメッセージをmacOS通知で表示
- tmux使用時はウィンドウとペイン番号も表示(例: `[1-0] Claude Code`)
- セキュリティ: `~/.claude/projects/` 内のファイルのみ読み取り可能
#!/usr/bin/env node
const { execFileSync } = require("node:child_process");
const { readFileSync, appendFileSync } = require("node:fs");
const path = require("node:path");
const os = require("node:os");
try {
const inputRaw = readFileSync(process.stdin.fd, 'utf8');
const input = JSON.parse(inputRaw);
// Debug log - write raw input to debug.log
const debugPath = path.join(os.homedir(), 'debug.log');
const timestamp = new Date().toISOString();
appendFileSync(debugPath, `\n[${timestamp}] stop-send-notification.js received:\n${inputRaw}\n`);
if (!input.transcript_path) {
appendFileSync(debugPath, `[${timestamp}] No transcript_path, exiting\n`);
process.exit(0);
}
const homeDir = os.homedir();
let transcriptPath = input.transcript_path;
if (transcriptPath.startsWith('~/')) {
transcriptPath = path.join(homeDir, transcriptPath.slice(2));
}
const allowedBase = path.join(homeDir, '.claude', 'projects');
const resolvedPath = path.resolve(transcriptPath);
appendFileSync(debugPath, `[${timestamp}] Resolved path: ${resolvedPath}\n`);
if (!resolvedPath.startsWith(allowedBase)) {
appendFileSync(debugPath, `[${timestamp}] Path not allowed, exiting\n`);
process.exit(1);
}
let lines;
try {
lines = readFileSync(resolvedPath, "utf-8").split("\n").filter(line => line.trim());
appendFileSync(debugPath, `[${timestamp}] Read ${lines.length} lines from transcript\n`);
} catch (e) {
// File not found or not readable
appendFileSync(debugPath, `[${timestamp}] Error reading file: ${e.message}\n`);
process.exit(0);
}
if (lines.length === 0) {
appendFileSync(debugPath, `[${timestamp}] No lines in file, exiting\n`);
process.exit(0);
}
const lastLine = lines[lines.length - 1];
let transcript, lastMessageContent;
try {
transcript = JSON.parse(lastLine);
lastMessageContent = transcript?.message?.content?.[0]?.text;
appendFileSync(debugPath, `[${timestamp}] Got message content: ${lastMessageContent ? 'YES' : 'NO'}\n`);
} catch (e) {
appendFileSync(debugPath, `[${timestamp}] Error parsing JSON: ${e.message}\n`);
process.exit(0);
}
if (lastMessageContent) {
// Get tmux window and pane info
let tmuxInfo = "";
try {
const windowIndex = execFileSync('tmux', ['display-message', '-p', '#{window_index}'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
const paneIndex = execFileSync('tmux', ['display-message', '-p', '#{pane_index}'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
const paneTitle = execFileSync('tmux', ['display-message', '-p', '#{pane_title}'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
// Remove emoji prefix if present
const cleanTitle = paneTitle.replace(/^[✳️✳]\s*/, '');
// Format: Claude Code - [window-pane] process/title
tmuxInfo = `Claude Code - [${windowIndex}-${paneIndex}] ${cleanTitle}`;
} catch {
// Not in tmux or tmux command failed, skip prefix
}
// Remove newlines and normalize whitespace
const cleanedMessage = lastMessageContent
.replace(/\n+/g, ' ') // Replace newlines with space
.replace(/\s+/g, ' ') // Normalize multiple spaces
.trim();
// Truncate message to fit macOS notification limits (around 240 chars)
const maxLength = 235;
const truncatedMessage = cleanedMessage.length > maxLength
? cleanedMessage.substring(0, maxLength - 3) + '...'
: cleanedMessage;
// Create notification title - put Claude Code after tmux info
const notificationTitle = tmuxInfo ? `${tmuxInfo.trim()}` : 'Claude Code';
// Use AppleScript for notification
const script = `display notification "${truncatedMessage.replace(/"/g, '\\"').replace(/\\/g, '\\\\')}" with title "${notificationTitle.replace(/"/g, '\\"')}" sound name "default"`;
appendFileSync(debugPath, `[${timestamp}] Sending notification with title: ${notificationTitle}\n`);
appendFileSync(debugPath, `[${timestamp}] Message: ${truncatedMessage}\n`);
try {
execFileSync('osascript', ['-e', script], {
stdio: 'pipe'
});
appendFileSync(debugPath, `[${timestamp}] Notification sent successfully\n`);
} catch (e) {
appendFileSync(debugPath, `[${timestamp}] Notification error: ${e.message}\n`);
}
}
} catch (error) {
// Silent exit on error - hooks should not output to stderr
process.exit(0);
}

技術解説: tmux ペインタイトルと Claude Code の統合

概要

このドキュメントでは、Claude Code が tmux ペインタイトルを設定する方法と、私たちの通知フックがそれらを取得する方法について説明します。

tmux ペインタイトルの仕組み

ペインタイトルの設定

tmux ペインタイトルは以下の方法で設定できます:

  1. プロセスからの自動設定: tmux は実行中のプロセス名を自動的に使用できます
  2. 手動設定: printf '\033]2;%s\033\\' "My Title" のようなエスケープシーケンスを使用
  3. tmux コマンド: tmux select-pane -t %0 -T "My Title"

ペインタイトルの取得

# 現在のペインタイトルを取得
tmux display-message -p '#{pane_title}'

# タイトル付きで全ペインをリスト表示
tmux list-panes -F "#{pane_index}: #{pane_title}"

Claude Code がペインタイトルを設定する方法

Claude Code は、会話のコンテキストに基づいてターミナルタイトルを動的に更新するために ANSI エスケープシーケンスを使用しているようです。これは以下の方法で行われていると思われます:

  1. プロセスタイトルの変更: Claude Code が起動すると、そのプロセスタイトルを変更します
  2. 動的更新: 会話が進行するにつれて、現在のトピックを反映するようにタイトルを更新します
  3. 絵文字プレフィックス: アクティブな Claude Code セッションを示すために ✳ 絵文字を使用します

実装の詳細

Claude Code は Node.js の process.title またはターミナルエスケープシーケンスを使用していると思われます:

// 方法1: プロセスタイトル
process.title = "✳ Initial Greeting";

// 方法2: ANSI エスケープシーケンス
process.stdout.write('\x1b]0;✳ Initial Greeting\x07');

このアプローチの利点

  1. 視覚的識別: どのペインにどの会話があるかを簡単に確認できます
  2. コンテキスト認識: 会話のトピックに基づいてタイトルが更新されます
  3. tmux 統合: tmux のペイン管理とシームレスに連携します

私たちのフックがタイトルを取得する方法

私たちの通知フックは、tmux の display-message コマンドを使用してペイン情報を抽出します:

// ウィンドウインデックスを取得
const windowIndex = execFileSync('tmux', ['display-message', '-p', '#{window_index}'], {...});

// ペインインデックスを取得
const paneIndex = execFileSync('tmux', ['display-message', '-p', '#{pane_index}'], {...});

// ペインタイトルを取得
const paneTitle = execFileSync('tmux', ['display-message', '-p', '#{pane_title}'], {...});

tmux フォーマット文字列

  • #{window_index}: 現在のウィンドウ番号(0ベース)
  • #{pane_index}: ウィンドウ内の現在のペイン番号(0ベース)
  • #{pane_title}: 現在のペインタイトル
  • #{pane_current_command}: ペインで実行中のコマンド

macOS 通知での Unicode 処理

問題

macOS の AppleScript による通知は、環境変数を介して渡される Unicode 文字(日本語テキストなど)で問題が発生する可能性があります。

解決策

AppleScript の do shell scriptquoted form を使用して Unicode を適切に処理します:

do shell script "echo " & quoted form of "日本語テキスト"
set msg to result
display notification msg

これにより適切なエンコーディングが保証され、通知での文字化けを防ぎます。

セキュリティの考慮事項

このフックには、許可されたディレクトリからのみ読み取りを行うためのパス検証が含まれています:

const allowedBase = path.join(homeDir, '.claude', 'projects');
if (!resolvedPath.startsWith(allowedBase)) {
    process.exit(1);
}

これにより、システム上の任意のファイルを読み取ることによる潜在的なセキュリティ問題を防ぎます。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment