はじめに #
ある日突然、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点です。
- hookの親
shの SID と PID が同じ(782087) → 自分が session leader。新しいセッションを切ってます。STATのSsのsが session leader の印 - TT列が
?→ ctty を持っていません - Claude本体(781302)は SIDが772667、TTは
pts/13→ ターミナルの bashと同じセッションでちゃんと ctty を持ってます
つまり Claude → sh の遷移で setsid() 相当が呼ばれて、新しいセッションが作られています。Node.js の child_process.spawn に detached: 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
terminalSequencefield 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バージョン後にちゃんと公式の代替が用意されていた、という間抜けなオチ付きです。
ツールが急に動かなくなった時、ps で SID / PGID / TT / STAT を眺めると一発で構造が見えるので、覚えておくと得かもしれません。
setsid 便利ですね、ただし「親ターミナルに何か書きたい」系のhookは全部壊すので、ホスト側(今回ならClaude Code)が代理書きの口を用意してるかチェックしましょう。
おまけ:複数のClaudeタブを並行で動かしていると、どれが作業中でどれが停止中か分からなくなるので、タイトルに状態絵文字を仕込むと地味に効きます。
| Hook | 表示 | 意味 |
|---|---|---|
UserPromptSubmit / PreToolUse |
🔄 <branch> |
作業中 |
Stop |
⛔ <branch> + bell |
停止 |
PermissionRequest |
🔐 <branch> + bell |
承認待ち |
Alt-Tabするだけで状態が分かるのでおすすめです。
参考 #
- Claude Code CHANGELOG — v2.1.139 / v2.1.141
- Claude Code Hooks ドキュメント —
terminalSequenceの仕様 - POSIX:
open(/dev/tty)ENXIO 仕様 - Node.js
child_process.spawnのdetachedオプション