This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new 721c639 feat(agent-isolation): add opt-in terminal-tint helper for
waiting state (#456)
721c639 is described below
commit 721c639e31041e3ffe1c8acf27d6ee11c4dffe89
Author: Jarek Potiuk <[email protected]>
AuthorDate: Fri Jun 5 18:35:13 2026 +0200
feat(agent-isolation): add opt-in terminal-tint helper for waiting state
(#456)
Ship claude-term-bg.sh, an optional quality-of-life helper (not a
security control) that keeps a calm baseline terminal background and
tints it only when Claude Code genuinely wants the operator to act —
never while it is working.
Two states across five user-scope hooks: Stop -> wait (turn finished,
tint); Notification -> notify (tint only for permission prompts, calm
for the plain idle ping); and PreToolUse + UserPromptSubmit +
SessionStart -> reset (calm while working, after a reply, and on a
fresh session — SessionStart also clears a stale tint a prior session
left in the terminal).
Two mechanics the helper gets right and the doc explains:
- Hooks run with no controlling terminal, so /dev/tty is useless;
find_tty_dev() walks the process tree to the Claude pty and writes
the OSC escape there.
- iTerm2 honours OSC 11 (set) but not OSC 111 (reset), so the only
deterministic reset is an explicit colour via CLAUDE_RESET_BG; the
fallback emits OSC 111 plus iTerm2's SetColors=bg=default.
Wires into the secure-agent-setup doc as an explicitly-optional,
default-off install step (asked during setup, mirroring the status
line), with the helper added to the agent-isolation README and a new
"Waiting-for-input terminal tint" section (Install / Verify /
Trade-offs). Framed throughout as a UX convenience, not a security
mechanism.
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
docs/setup/secure-agent-setup.md | 154 +++++++++++++++++++++++++++++++-
tools/agent-isolation/README.md | 1 +
tools/agent-isolation/claude-term-bg.sh | 100 +++++++++++++++++++++
3 files changed, 254 insertions(+), 1 deletion(-)
diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md
index e352144..1e5ff5d 100644
--- a/docs/setup/secure-agent-setup.md
+++ b/docs/setup/secure-agent-setup.md
@@ -32,6 +32,7 @@
- [Verify](#verify-1)
- [Trade-offs](#trade-offs-1)
- [Sandbox-state status line](#sandbox-state-status-line)
+ - [Waiting-for-input terminal tint](#waiting-for-input-terminal-tint)
- [Syncing user-scope config across
machines](#syncing-user-scope-config-across-machines)
- [What to track, what not to track](#what-to-track-what-not-to-track)
- [Layout](#layout)
@@ -1158,6 +1159,141 @@ bold red.
read that field directly. Until then the file-read approach is
the only option, with the trade-off above.
+## Waiting-for-input terminal tint
+
+> **Quality-of-life helper, not a security control.** Unlike the
+> rest of this document, this piece protects nothing — it just
+> makes the "Claude is blocked on me" state impossible to miss. It
+> rides the same user-scope-hook install machinery as the helpers
+> above, which is why it lives here, but it is entirely optional
+> and off by default.
+
+When you run several agents across tabs, it is easy to leave one
+sitting at a permission prompt or a finished turn while you work
+elsewhere. The framework ships
+[`tools/agent-isolation/claude-term-bg.sh`](../../tools/agent-isolation/claude-term-bg.sh)
+to make a **calm baseline the normal state and tint the background
+only when Claude genuinely wants you to act** — never while it is
+working. The model is two states, wired across five hooks:
+
+| Moment | Hook → action | Background |
+|---|---|---|
+| Turn finished — your turn to respond | `Stop` → `wait` | tinted (muted
indigo `#2a1a3a`) |
+| Blocked on a permission prompt | `Notification` → `notify` | tinted |
+| Actively working (running a tool) | `PreToolUse` → `reset` | calm |
+| Fresh/idle session, plain idle ping | `SessionStart` / `Notification` →
`reset`/`notify` | calm |
+| You submit a reply | `UserPromptSubmit` → `reset` | calm |
+
+Three details make the model behave:
+
+- **`PreToolUse` → `reset`** fires on every tool call, so the moment
+ Claude resumes work — including right after you approve a
+ permission prompt that had tinted the screen — it returns to calm.
+ Without it, an approved-permission tint would linger until the
+ turn ended.
+- **`SessionStart` → `reset`** clears any tint a *previous* session
+ left behind (OSC background changes persist in the terminal
+ across processes, so a session closed mid-wait would otherwise
+ hand its tint to the next one — making a "fresh" session look
+ like it is waiting on you).
+- **`Notification` → `notify`** is selective: the same hook fires
+ both for permission prompts *and* the plain 60-second idle ping,
+ so the script reads the notification payload on stdin and tints
+ only when the message is a permission/attention prompt; an idle
+ session stays calm.
+
+**Two mechanics make this work** (both are easy to get wrong):
+
+1. **Hooks have no controlling terminal.** Claude Code spawns hook
+ commands detached from the tty, so `/dev/tty` does not resolve
+ to your window — a naive `printf '\033]11;…' > /dev/tty` writes
+ nowhere. The script walks up the process tree from `$PPID` to
+ find the Claude process's pty (e.g. `/dev/ttys003`) and writes
+ the escape straight to that device.
+2. **Set and reset are not symmetric.** iTerm2 honours OSC 11 (set
+ background) but does **not** reliably honour OSC 111
+ (reset-to-default) through Claude's fullscreen TUI, so a naive
+ reset leaves the tint stuck on. The script resets
+ belt-and-braces: it emits both OSC 111 *and* iTerm2's
+ proprietary `SetColors=bg=default`. For a guaranteed reset on
+ any terminal, set `CLAUDE_RESET_BG` to your normal background
+ colour and the script re-applies it via OSC 11 (the path that
+ is known to work since the tint itself does).
+
+**Why user-scope.** Same reasoning as the helpers above: you want
+the signal in every session on the host, not only tracker
+sessions. Install in `~/.claude/settings.json`.
+
+**Install (user-scope).**
+
+```bash
+mkdir -p ~/.claude/scripts
+cp /path/to/airflow-steward/tools/agent-isolation/claude-term-bg.sh \
+ ~/.claude/scripts/claude-term-bg.sh
+chmod +x ~/.claude/scripts/claude-term-bg.sh
+```
+
+Wire it into `~/.claude/settings.json` under four hook events. If
+you already have hooks on any of these events, add the command as
+an extra entry rather than replacing the existing array. The
+`CLAUDE_RESET_BG=#000000` prefix makes the calm state a
+deterministic black (recommended — it sidesteps the OSC-111 reset
+gap described above); drop it to fall back to profile-default
+reset.
+
+```jsonc
+{
+ "hooks": {
+ "Stop": [
+ { "hooks": [ { "type": "command", "command":
"~/.claude/scripts/claude-term-bg.sh wait" } ] }
+ ],
+ "PreToolUse": [
+ { "matcher": "*", "hooks": [ { "type": "command", "command":
"CLAUDE_RESET_BG=#000000 ~/.claude/scripts/claude-term-bg.sh reset" } ] }
+ ],
+ "UserPromptSubmit": [
+ { "hooks": [ { "type": "command", "command": "CLAUDE_RESET_BG=#000000
~/.claude/scripts/claude-term-bg.sh reset" } ] }
+ ],
+ "SessionStart": [
+ { "hooks": [ { "type": "command", "command": "CLAUDE_RESET_BG=#000000
~/.claude/scripts/claude-term-bg.sh reset" } ] }
+ ],
+ "Notification": [
+ { "hooks": [ { "type": "command", "command": "CLAUDE_RESET_BG=#000000
~/.claude/scripts/claude-term-bg.sh notify" } ] }
+ ]
+ }
+}
+```
+
+Override the colours via the environment: `CLAUDE_WAIT_BG`
+(default `#2a1a3a`) and `CLAUDE_RESET_BG` (default unset → reset to
+the profile default; set to a colour like `#000000` for a
+deterministic calm background).
+
+**Verify.** The hooks fire on real events, so the quickest check
+is live: start a session, let a turn finish, and confirm the
+background tints; then send a reply and confirm it resets. To
+sanity-check the script in isolation, run it against your own
+terminal device:
+
+```bash
+~/.claude/scripts/claude-term-bg.sh wait # background tints
+~/.claude/scripts/claude-term-bg.sh reset # background restored
+```
+
+(Run directly from an interactive shell, the script finds the
+shell's pty via `$PPID` and writes there.)
+
+**Trade-offs.**
+
+- **iTerm2-tested, fail-soft elsewhere.** The set path uses OSC 11,
+ which most modern terminal emulators support; terminals that
+ ignore it simply show no change. The reset path is hardened for
+ iTerm2's OSC-111 gap specifically. On a terminal where reset
+ misbehaves, set `CLAUDE_RESET_BG` for a deterministic restore.
+- **Cosmetic only.** It conveys no sandbox or permission state —
+ pair it with the [Sandbox-state status line](#sandbox-state-status-line)
+ and [Sandbox-bypass visibility hook](#sandbox-bypass-visibility-hook)
+ for the security-relevant signals.
+
## Syncing user-scope config across machines
The user-scope pieces of the secure setup —
@@ -1545,7 +1681,23 @@ Then walk through:
(e.g. I have other PreToolUse hooks for unrelated work),
surface the merge diff and ask me to approve before writing.
-6. **Verify.** After everything is in place, walk through the
+6. **(Optional) Waiting-for-input terminal tint.** Ask me whether
+ I want the terminal background to tint while Claude is waiting
+ on me (a pure quality-of-life signal, no security effect).
+ **Default no.** Only if I say yes: copy
+ `<airflow-steward>/tools/agent-isolation/claude-term-bg.sh`
+ into `~/.claude/scripts/` and `chmod +x` it, then add five
+ hooks to `~/.claude/settings.json`, merging into any existing
+ arrays on those events — `Stop` → `claude-term-bg.sh wait`;
+ `UserPromptSubmit`, `SessionStart`, and `PreToolUse` (matcher
+ `*`) → `claude-term-bg.sh reset`; and `Notification` →
+ `claude-term-bg.sh notify`. Ask whether I want the calm state
+ to be a deterministic black (prefix the reset/notify commands
+ with `CLAUDE_RESET_BG=#000000`) or the terminal's profile
+ default. See
+ [Waiting-for-input terminal tint](#waiting-for-input-terminal-tint).
+
+7. **Verify.** After everything is in place, walk through the
Verification checks from the next section of this document
("Verification — Via a Claude Code prompt") and report
✓ done / ✗ missing / ⚠ partial for each piece.
diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md
index 2e07f89..228f915 100644
--- a/tools/agent-isolation/README.md
+++ b/tools/agent-isolation/README.md
@@ -34,6 +34,7 @@ versions.
| [`sandbox-error-hint.sh`](sandbox-error-hint.sh) | Claude Code `PostToolUse`
hook (Bash matcher). Scans the tool's stdout + stderr for the three known
sandbox-shaped error signatures (SSH agent / Yubikey unreachable, loopback
port-bind blocked, docker / podman socket denied) and prints a `[sandbox-hint]`
line pointing at the matching entry in
[`docs/setup/sandbox-troubleshooting.md`](../../docs/setup/sandbox-troubleshooting.md).
Fail-open: any unexpected JSON shape exits silent. Recomm [...]
| [`sandbox-status-line.sh`](sandbox-status-line.sh) | Claude Code
`statusLine` helper. Renders `<model> [sandbox]` (green) or `<model> [NO
SANDBOX]` (bold red) based on `sandbox.enabled` in the active settings —
project `settings.local.json` first, then project `settings.json`, then
user-scope, mirroring Claude Code's own precedence. Reflects in-session
`/sandbox` toggles (which persist to project `settings.local.json`).
Recommended user-scope. |
| [`sandbox-status-line-rich.sh`](sandbox-status-line-rich.sh) | Opt-in richer
alternative to `sandbox-status-line.sh`. Same sandbox-state detection, plus
folder name (hash-coloured), git branch + dirty + ahead/behind, per-branch PR
title (cached, gated by `gh`), and a yellow `[sandbox-auto]` tag for the
`autoAllowBashIfSandboxed` setting. Wire one *or* the other into
`statusLine.command`. |
+| [`claude-term-bg.sh`](claude-term-bg.sh) | **Opt-in quality-of-life helper
(not a security control).** Keeps a calm baseline background and tints it only
when Claude genuinely wants you to act (never while working), so a window
you've tabbed away from can't sit blocked unnoticed. Two states across five
hooks: `Stop` → `wait` (turn finished, tint); `UserPromptSubmit` +
`SessionStart` + `PreToolUse` → `reset` (calm — `PreToolUse` keeps it calm
while a tool runs and clears an approved-per [...]
| [`sandbox-add-project-root.sh`](sandbox-add-project-root.sh) | Adds the
current adopter repo's project root (and, with `--all-worktrees`, every linked
git worktree's working dir) as an explicit absolute path to
`sandbox.filesystem.allowRead` and `allowWrite` in the project-local,
gitignored `<repo>/.claude/settings.local.json` — one entry per worktree, each
in that worktree's own settings file. Defensive against [issue
#197](https://github.com/apache/airflow-steward/issues/197) — `allo [...]
| [`git-global-post-checkout.sh`](git-global-post-checkout.sh) | Universal
`post-checkout` git hook installed at `~/.claude/git-hooks/post-checkout` when
the operator picks **whole-user** scope in `setup-isolated-setup-install`.
Activated by `git config --global core.hooksPath ~/.claude/git-hooks/` so every
`git checkout` / `git clone` / `git worktree add` across the host invokes it.
Two responsibilities (both best-effort + idempotent + `\|\| true`): (1) `setup
verify --auto-fix-symlinks [...]
diff --git a/tools/agent-isolation/claude-term-bg.sh
b/tools/agent-isolation/claude-term-bg.sh
new file mode 100755
index 0000000..d8f730f
--- /dev/null
+++ b/tools/agent-isolation/claude-term-bg.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# claude-term-bg.sh — tint the terminal background while Claude Code is
+# waiting on YOU, and keep it calm the rest of the time.
+#
+# This is a quality-of-life helper, NOT a security control: it makes the
+# "Claude is blocked on me" state impossible to miss in a window you've
+# tabbed away from. Wire it into five Claude Code hooks (user-scope):
+#
+# "Stop" -> claude-term-bg.sh wait (turn finished — your
turn, tint)
+# "UserPromptSubmit" -> claude-term-bg.sh reset (you replied — back to
calm)
+# "SessionStart" -> claude-term-bg.sh reset (fresh session — clear
any stale tint)
+# "PreToolUse" -> claude-term-bg.sh reset (actively working — stay
calm; also
+# clears an
approved-permission tint)
+# "Notification" -> claude-term-bg.sh notify (tint for permission
prompts only;
+# the plain idle ping
stays calm)
+#
+# Colours are overridable via the environment (e.g. inline in the hook
command):
+# CLAUDE_WAIT_BG background while waiting (default: #2a1a3a, a muted
indigo)
+# CLAUDE_RESET_BG calm/idle background (default: unset -> reset to
profile default;
+# set e.g. to #000000 for a
deterministic black)
+#
+# Mechanism notes (the two things that make this actually work):
+#
+# 1. No controlling terminal. Claude Code spawns hook commands detached
+# from the tty, so /dev/tty does not resolve to your terminal window.
+# find_tty_dev() walks up the process tree to the Claude process's pty
+# (e.g. /dev/ttys003) and writes the escape straight to that device.
+#
+# 2. Set vs. reset asymmetry. iTerm2 honours OSC 11 (set background) but
+# does NOT reliably honour OSC 111 (reset-to-default) through Claude's
+# fullscreen TUI, so a naive reset leaves the tint stuck. The only
+# deterministic reset is an explicit colour via CLAUDE_RESET_BG (OSC 11,
+# the path we know works); without it we emit BOTH OSC 111 and iTerm2's
+# proprietary SetColors=bg=default and let whichever the terminal
+# understands win.
+#
+# Tested on iTerm2 + macOS. Terminals that ignore OSC 11 entirely simply see
+# no change (fail-soft). For a guaranteed reset anywhere, set CLAUDE_RESET_BG.
+set -u
+WAIT_BG="${CLAUDE_WAIT_BG:-#2a1a3a}"
+RESET_BG="${CLAUDE_RESET_BG:-}"
+action="${1:-reset}"
+
+find_tty_dev() {
+ local pid=${PPID:-$$} t i
+ for i in 1 2 3 4 5 6 7 8; do
+ [ -z "$pid" ] || [ "$pid" = "0" ] || [ "$pid" = "1" ] && break
+ t=$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')
+ case "$t" in
+ ttys*|tty[0-9]*) echo "/dev/$t"; return 0 ;;
+ esac
+ pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
+ done
+ return 1
+}
+
+tty_dev=$(find_tty_dev || true)
+[ -n "${tty_dev:-}" ] && [ -w "$tty_dev" ] || exit 0
+
+set_wait() { printf '\033]11;%s\007' "$WAIT_BG" > "$tty_dev"; }
+set_reset() {
+ if [ -n "$RESET_BG" ]; then
+ printf '\033]11;%s\007' "$RESET_BG" > "$tty_dev"
+ else
+ printf '\033]111\007' > "$tty_dev" # xterm: reset bg
+ printf '\033]1337;SetColors=bg=default\007' > "$tty_dev" # iTerm2
proprietary
+ fi
+}
+
+case "$action" in
+ wait|set) set_wait ;;
+ reset|off) set_reset ;;
+ notify)
+ # Notification fires for permission prompts AND the plain 60s idle ping.
+ # Tint only when it's something that genuinely wants the user to act.
+ msg=$(cat 2>/dev/null)
+ case "$msg" in
+ *permission*|*approve*|*"needs your"*) set_wait ;;
+ *) set_reset ;;
+ esac
+ ;;
+esac
+exit 0