メインコンテンツへスキップ

Claude Code hookで /dev/tty が使えなくなった話

kiitosu
著者
kiitosu
aws community builder. 画像処理やデバイスドライバ、データ基盤構築からWebバックエンドまで、多様な領域に携わってきました。地図解析や地図アプリケーションの仕組みにも経験があり、幅広い技術を活かした開発に取り組んでいます。休日は草野球とランニングを楽しんでいます。
目次

はじめに
#

ある日突然、Claude Code で発言するとこのエラーで止まるようになりました。

● UserPromptSubmit operation blocked by hook:
  [printf '\033]0;🔄 %s\007' "$(git branch --show-current 2>/dev/null || echo 'no-branch')" > /dev/tty]:
  /bin/sh: 1: cannot create /dev/tty: No such device or address

UserPromptSubmit hookで「タブのタイトルに 🔄 <ブランチ名> を出す」ために printf ... > /dev/tty していたところ、/dev/tty が開けないと言われています。 昨日まで動いてたのに何で?

結論
#

  • Claude Code v2.1.139 で hookプロセスが 新しいセッション(setsid 相当・ctty なし) で起動するようになりました
  • /dev/tty は「セッションのctty」を指す抽象なので、ctty を持たないセッションでは open できません(ENXIO
  • v2.1.141 から公式の代替手段 terminalSequence フィールド が用意されています。hookが JSON で返せば Claude本体が代理で端末に書いてくれます
  • 最初これを知らずに /dev/pts/N 直叩きという汚い回避策で凌いでいた話です

/dev/tty ってそもそも何だっけ
#

ファイル名のように見えますが、実は 「自分が今いるセッションの ctty」 を指す特殊な参照です。 セッションが ctty を持っていなければ、/dev/tty を open しても「そんなデバイスはない」と言われます(POSIX仕様)。

イメージはこんな感じ。

  • TTY=端末。/dev/pts/13 みたいなのが実体
  • ctty=そのプロセスに紐付いた「主たる端末」。Ctrl-C が飛んでいく先
  • セッション=プロセスのグループ。1セッションに ctty は1つ
  • /dev/tty =「自分のセッションの ctty を呼んでくれ」というショートカット

つまり /dev/tty は「住所」じゃなくて「私の家」みたいな相対指定です。家を持っていないプロセスがこれを叩いても No such device になります。

hookプロセスがどんな状態かを見てみる
#

debug-tty.sh というデバッグ用スクリプトを書いて、これを UserPromptSubmit hookに登録します。自分(hookプロセス)と祖先プロセスの SID / PGID / TT を ps で吐かせて、どこでセッションが切り替わっているか見るのが狙いです。

#!/bin/bash
# debug-tty.sh - hookとして登録するデバッグスクリプト
LOG=/tmp/claude_hook_debug.log
{
  echo "tty(): $(tty 2>&1 || true)"
  ps -o pid,ppid,sid,pgid,tty,stat,cmd -p $$       # 自分自身
  P=$PPID
  for _ in 1 2 3 4 5; do                            # 親を5世代さかのぼる
    ps -o pid,ppid,sid,pgid,tty,stat,cmd -p "$P"
    P=$(ps -o ppid= -p "$P" | tr -d ' ')
  done
} >> "$LOG" 2>&1

settings.json に登録:

"UserPromptSubmit": [{
  "matcher": "",
  "hooks": [{ "type": "command", "command": "~/.claude/hooks/debug-tty.sh" }]
}]

Claudeに何か発言すると、hookが走って /tmp/claude_hook_debug.log に追記されます。中身がこれ。

tty(): not a tty
    PID    PPID     SID    PGID TT       STAT CMD
 782088  782087  782087  782087 ?        S    /bin/bash debug-tty.sh    ← hook本体
 782087  781302  782087  782087 ?        Ss   /bin/sh -c debug-tty.sh   ← hookの親
 781302  772667  772667  781302 pts/13   Sl+  claude                    ← その親
 772667  772665  772667  772667 pts/13   Ss   -bash                     ← ターミナル

見るべきは3点です。

  1. hookの親 shSID と PID が同じ(782087) → 自分が session leader。新しいセッションを切ってます。STATの Sss が session leader の印
  2. TT列が ? → ctty を持っていません
  3. Claude本体(781302)は SIDが772667、TTは pts/13 → ターミナルの bashと同じセッションでちゃんと ctty を持ってます

つまり Claude → sh の遷移で setsid() 相当が呼ばれて、新しいセッションが作られています。Node.js の child_process.spawndetached: true を渡すとこの挙動になります。

何が変わったのか — CHANGELOG 確認
#

最初は「Claude Code側で何かやらかしたかな?」と思ったのですが、ちゃんと公式のCHANGELOG に書いてありました。

v2.1.139: Fixed a bug where a hook writing to the terminal could corrupt an on-screen interactive prompt; hooks now run without terminal access.

hooksをterminal accessなしで動かすように変更したよ」と。「on-screen interactive promptが壊れる事故を防ぐため」というのが理由です。 isolation としては正しいですね。/dev/tty 経由でhookが好き勝手に端末へ書けてしまうと、Claudeが描画中のプロンプトを上書きして表示が壊れる可能性があります。

bug fix の名目で詰められたわけですが、副作用で「hookから端末に何か書きたい」系はいったん全滅しました。

知らずにやっていた汚い回避策:pts直叩き
#

CHANGELOGをちゃんと読まずに自力でなんとかしようとした結果、こんなことをしていました。

/dev/tty がダメでも、/dev/pts/13 のような 具体的なデバイスファイル には書けます。ctty じゃないからpermissionさえ通れば open できます。 親プロセスをさかのぼって、最初に出てきた /dev/pts/* に書きます。

#!/bin/bash
# set-title.sh - 汚い回避策。今は使わない方が良い
EMOJI=${1:-🔄}

P=$PPID
TTY_DEV=""
for _ in 1 2 3 4 5 6 7 8; do
  T=$(ps -o tty= -p "$P" 2>/dev/null | tr -d ' ')
  case "$T" in
    pts/*) TTY_DEV="/dev/$T"; break;;
  esac
  P=$(ps -o ppid= -p "$P" 2>/dev/null | tr -d ' ')
done

BRANCH=$(git branch --show-current 2>/dev/null || echo 'no-branch')
[ -n "$TTY_DEV" ] && printf '\033]0;%s %s\007' "$EMOJI" "$BRANCH" > "$TTY_DEV" 2>/dev/null
exit 0

これで動きはしましたが、問題点が複数あります。

  • 正しいpts に書ける保証がない: 親をさかのぼる経路にtmuxやscreenが挟まると、自分が見ているターミナルと違うptsを掴むことがある
  • Windowsでは動かない: そもそもptsデバイスが存在しない
  • 書き込み権限まわりの罠: 別ユーザのpts に当たると刺さる可能性
  • 公式が「やめてくれ」と明言した穴を、ファイルパス経由で迂回しているだけ: 設計意図に反している

正しい解決:terminalSequence フィールド
#

CHANGELOG を遡って読んでいたら、その2バージョン後にこう書いてありました。

v2.1.141: Added terminalSequence field to hook JSON output so hooks can emit desktop notifications, window titles, and bells without a controlling terminal.

これだ……。完全に自分のユースケース向けに用意された公式機能です。 仕組みは単純で、hookが標準出力に {"terminalSequence": "<エスケープシーケンス>"} というJSONを返すと、Claude本体が代わりに端末にそれを書いてくれます。Claudeはctty を持っているので何の問題もなく書けるわけですね。

正式版のスクリプトはこうなります。

#!/bin/bash
# set-title.sh <emoji> [--bell]
EMOJI=${1:-🔄}
BELL=""
[ "$2" = "--bell" ] && BELL=$'\a'

BRANCH=$(git branch --show-current 2>/dev/null || echo 'no-branch')
SEQ=$'\033]0;'"$EMOJI $BRANCH"$'\007'"$BELL"

jq -nc --arg seq "$SEQ" '{terminalSequence: $seq}'

settings.json 側は同じです。

"UserPromptSubmit": [{
  "matcher": "",
  "hooks": [{ "type": "command", "command": "~/.claude/hooks/set-title.sh 🔄" }]
}],
"Stop": [{
  "matcher": "",
  "hooks": [{ "type": "command", "command": "~/.claude/hooks/set-title.sh ⛔ --bell" }]
}]

これで動きます。タブのタイトルもベルも復活しました!

terminalSequence の良いところ
#

項目 pts直叩き terminalSequence
正しい端末に届くか あやしい(祖先たどり) 確実(Claudeが自分のcttyに書く)
Windows対応 不可 (OSなしで同じJSONが効く)
tmux/screen 経路で詰まりがち race-free
設計意図 公式の穴を迂回 公式の指定経路

加えて allowlist でガードされています。OSC 0/1/2/9/99/777 と BEL のみ通る仕様で、カーソル移動や色変更みたいに画面を壊せるシーケンスは弾かれます。「on-screen promptが壊れる」を防ぐという v2.1.139 の元々の目的を、ちゃんと両立させた設計になっています。

最後に
#

/dev/tty がファイルパスじゃなくて 「セッションが持つcttyへの参照」 という抽象だった、というのが今回の学びです。普段「ファイルとして扱える」と思っているものが、実はセッションコンテキストに依存していたのに気づかされました。

それと、もう一個の学び。動かなくなったら自力でなんとかする前に CHANGELOG を読む。自分はそれをサボったせいで、pts直叩きという汚い回避策を一回作って動かして「やったぞ!」となっていました。実は2バージョン後にちゃんと公式の代替が用意されていた、という間抜けなオチ付きです。

ツールが急に動かなくなった時、psSID / PGID / TT / STAT を眺めると一発で構造が見えるので、覚えておくと得かもしれません。 setsid 便利ですね、ただし「親ターミナルに何か書きたい」系のhookは全部壊すので、ホスト側(今回ならClaude Code)が代理書きの口を用意してるかチェックしましょう。

おまけ:複数のClaudeタブを並行で動かしていると、どれが作業中でどれが停止中か分からなくなるので、タイトルに状態絵文字を仕込むと地味に効きます。

Hook 表示 意味
UserPromptSubmit / PreToolUse 🔄 <branch> 作業中
Stop ⛔ <branch> + bell 停止
PermissionRequest 🔐 <branch> + bell 承認待ち

Alt-Tabするだけで状態が分かるのでおすすめです。

参考
#

Reply by Email

関連記事

Obsidian CLIとClaude Codeでタスク管理を改善した話
ECS Exec の stdin pipe もいいが SSM Port Forwarding はもっといい
Authenticatorアプリの仕組み — MFAの中のTOTPを自作する