Skip to content
Gary Wu
Go back

Ghostty, Tmux, and AI Integration

Edit page

Org Status: 🟒 Active Cloudflare: N/A Last Audited: 2026-04-28


AI coding agents run in terminals. They finish tasks, need permissions, hit errors, and wait for input β€” all while you are looking at another pane, another window, or your phone. The feedback loop between agent and human is broken by default. This article shows how to fix it using Ghostty, tmux, Claude Code hooks, and a macOS notch HUD, turning your terminal into an agent-aware operating surface.

What you will learn:


  1. The Problem
  2. Core Concepts
  3. Patterns
  4. Small Examples
  5. Comparisons
  6. Anti-Patterns
  7. References

You are running four Claude Code agents in parallel tmux sessions. One finishes and needs a PR review. Another hits a permission prompt and is blocked. A third is still churning through a refactor. The fourth errored out on a rate limit ten minutes ago.

You know none of this. You are staring at pane 2.

The terminal was designed for a single human typing commands. AI agents broke that model. An agent session can run for minutes without human input, then suddenly need attention. The feedback mechanisms available in a raw terminal β€” cursor blinking, bell character, scrolling output β€” were never designed for this.

What goes wrong without integration:

What changes if you get this right:

Your tmux status bar shows 3 working | 1 needs-input at a glance. Ghostty fires a desktop notification when an agent finishes. A macOS notch HUD shows a live summary of what each agent is doing. You never poll. You respond to events. Your throughput with parallel agents doubles because you eliminate idle time.


Ghostty Integration Points

Ghostty is a GPU-accelerated terminal emulator by Mitchell Hashimoto. It is fast, minimal in configuration, and increasingly scriptable. Here are the integration surfaces that matter for agent automation.

AppleScript (macOS, Ghostty 1.3+)

Ghostty exposes a native AppleScript dictionary with a hierarchical object model:

application β†’ windows β†’ tabs β†’ terminals
// The Ghostty AppleScript object model, expressed as TypeScript
interface GhosttyApp {
  windows: GhosttyWindow[];
  terminals: GhosttyTerminal[];  // flat access across all windows
  frontWindow: GhosttyWindow;
}

interface GhosttyWindow {
  id: number;
  name: string;
  selectedTab: GhosttyTab;
  tabs: GhosttyTab[];
  terminals: GhosttyTerminal[];
}

interface GhosttyTab {
  id: number;
  name: string;
  index: number;
  selected: boolean;
  focusedTerminal: GhosttyTerminal;
  terminals: GhosttyTerminal[];
}

interface GhosttyTerminal {
  id: number;
  name: string;
  workingDirectory: string;
}

// Commands available on terminals
type GhosttyCommands = {
  inputText: (text: string, terminal: GhosttyTerminal) => void;
  sendKey: (key: string, modifiers?: ('shift' | 'control' | 'option' | 'command')[]) => void;
  sendMouseButton: (button: number, action: 'press' | 'release') => void;
  sendMousePosition: (x: number, y: number) => void;
  sendMouseScroll: (deltaX: number, deltaY: number) => void;
  performAction: (action: string) => void;
  split: (direction: 'right' | 'left' | 'down' | 'up') => void;
  focus: () => void;
  close: () => void;
};

// Surface configuration for new windows/tabs
interface SurfaceConfig {
  fontSize?: number;
  initialWorkingDirectory?: string;
  command?: string;
  initialInput?: string;
  waitAfterCommand?: boolean;
  environmentVariables?: Record<string, string>;
}

A practical AppleScript example β€” broadcast a command to every terminal:

tell application "Ghostty"
    repeat with w in windows
        repeat with t in tabs of w
            repeat with term in terminals of t
                input text "git status\n" to term
            end repeat
        end repeat
    end repeat
end tell

Key insight: Ghostty’s AppleScript is the only way to programmatically control Ghostty from outside the terminal. There is no socket, no HTTP API, no IPC channel. If you need external automation on macOS, AppleScript is the path. On Linux, Ghostty has no equivalent β€” you must work through tmux or shell scripts running inside the terminal.

OSC Sequences

Ghostty supports OSC 9 (iTerm2-style desktop notifications) and OSC 777 (rxvt-unicode-style notifications with title and body). These are escape sequences that any process running inside the terminal can emit:

printf '\e]9;Agent finished: refactor complete\a'

printf '\e]777;notify;Claude Code;Task complete: PR ready for review\a'

Ghostty also supports notification on command finish via configuration:

notify-on-command-finish = unfocused
notify-on-command-finish-action = notify
notify-on-command-finish-after = 10

This fires a macOS desktop notification if a command runs for more than 10 seconds and the terminal is not focused. It uses OSC 133 (semantic prompts) under the hood, so it requires shell integration or a shell that natively sends OSC 133 (Fish, Nushell).

Keybind Actions

Ghostty supports keybind sequences with tmux-like chaining:

keybind = ctrl+a>n=new_window
keybind = ctrl+a>s=new_split:right
keybind = ctrl+a>v=new_split:down

Actions relevant to automation include:

ActionDescription
write_scrollback_file:pasteDump scrollback to a temp file, paste the path into the terminal
write_screen_file:copyDump visible screen to a temp file, copy path to clipboard
write_selection_file:openWrite selected text to a temp file, open in default editor
new_split:autoCreate a split pane, auto-choose direction
close_surfaceClose the current pane/tab/window

The write_scrollback_file action is particularly useful: bind it to a key, then a script can read the temp file to extract the latest agent output without pipe-pane.

Custom Shaders

Ghostty exposes uniforms to GLSL shaders including focus state:

UniformTypeDescription
iFocusint0 = blurred, 1 = focused
iTimeFocusfloatiTime when last focused
iCursorColorvec4Current cursor color
iPalette[256]vec4[256]Full 256-color ANSI palette
iBackgroundColorvec4Terminal background color
iForegroundColorvec4Terminal foreground color

You can use iFocus to dim unfocused panes, creating a visual indicator of which pane is active. More on this in Pattern 7.

Key insight: Ghostty’s shader system is a visual feedback channel, not a data channel. You cannot pass arbitrary state to a shader. But you can change the terminal’s cursor color or background color from a shell script (via ANSI escape sequences), and the shader will see those changes through the uniform values. This creates an indirect communication path: hook script sets cursor color based on agent state, shader reacts to cursor color.


tmux Hook System

tmux hooks are commands that fire automatically on specific events. They are the backbone of any agent monitoring setup.

// tmux hook model
interface TmuxHook {
  event: TmuxHookEvent;
  index: number;       // hooks are arrays: after-new-window[0], after-new-window[1]
  command: string;      // any tmux command or shell command via run-shell
}

type TmuxHookEvent =
  // Window lifecycle
  | 'after-new-window'
  | 'after-kill-window'
  | 'window-renamed'
  // Pane lifecycle
  | 'after-new-session'
  | 'after-split-window'
  | 'after-kill-pane'
  | 'pane-died'
  | 'pane-exited'
  // Focus events
  | 'pane-focus-in'
  | 'pane-focus-out'
  | 'client-focus-in'
  | 'client-focus-out'
  // Session events
  | 'client-session-changed'
  | 'session-renamed'
  | 'client-attached'
  | 'client-detached'
  // Input events
  | 'after-send-keys'
  | 'after-copy-mode'
  // Layout events
  | 'after-select-layout'
  | 'after-resize-pane'
  | 'window-layout-changed'
  // Alert events
  | 'alert-activity'
  | 'alert-bell'
  | 'alert-silence'
  // Theme events (tmux 3.5+)
  | 'client-light-theme'
  | 'client-dark-theme';

Setting a hook:

tmux set-hook -g pane-focus-in 'run-shell "~/.tmux/hooks/on-focus.sh #{pane_id}"'

tmux set-hook -g pane-exited 'run-shell "~/.tmux/hooks/on-exit.sh #{pane_id} #{pane_dead_status}"'

tmux set-hook -g window-renamed 'refresh-client -S'

tmux set-hook -g pane-focus-in[0] 'select-pane -T "#{pane_current_command}"'
tmux set-hook -g pane-focus-in[1] 'refresh-client -S'

Key insight: tmux hooks fire tmux commands, not shell commands. To run a shell script, wrap it in run-shell. The run-shell command runs asynchronously by default β€” it does not block tmux. Variables like #{pane_id}, #{session_name}, and #{pane_current_path} are expanded before execution.

Status Bar Customization

The tmux status bar is the primary surface for showing agent state. Key options:

set -g status 2

set -g status-interval 5

set -g status-right '#(~/.tmux/scripts/agent-status.sh) | %H:%M'

set -g status-format[0] '#[align=left]#{agent_summary}#[align=right]#{session_name}'
set -g status-format[1] '#[align=left]#{agent_detail}#[align=right]#{git_branch}'

set -g status-style 'bg=#1e1e2e,fg=#cdd6f4'
set -g status-right-length 120
set -g status-left-length 40

pipe-pane and capture-pane

pipe-pane sends all output from a pane to an external command in real time. capture-pane takes a snapshot.

tmux pipe-pane -o "cat >> ~/logs/agent-#{pane_id}.log"

tmux pipe-pane -o 'grep --line-buffered "Task complete\|Error\|Permission" >> /tmp/agent-events.log'

tmux pipe-pane

tmux capture-pane -p -S -

tmux capture-pane -p -S - > /tmp/pane-snapshot.txt

Key insight: pipe-pane -o only captures output (what the program writes to the terminal), not input (what the user types). For agent monitoring, this is exactly what you want β€” you see the agent’s responses without the noise of your prompts. The -I flag adds input if you need it.

Control Mode

tmux control mode (tmux -CC) sends structured protocol messages instead of raw terminal output. iTerm2 uses this extensively to render tmux windows as native tabs and panes.

tmux -CC attach

No other terminal implements control mode integration besides iTerm2. Ghostty does not support it. This is a significant differentiator β€” if you rely on tmux -CC for native tab integration, iTerm2 is the only option.

DCS Passthrough

When running inside tmux, escape sequences from programs do not reach the outer terminal by default. tmux intercepts them. To pass OSC sequences through to Ghostty (or any terminal), enable passthrough:

set -g allow-passthrough on

The wrapped format doubles \033 characters and wraps in a DCS sequence:

printf '\e]9;Notification text\a'

printf '\ePtmux;\e\e]9;Notification text\a\e\\'

Most modern tools handle this wrapping automatically if allow-passthrough is enabled.


Claude Code Hooks

Claude Code hooks are the primary mechanism for integrating AI agent state with external systems. When a hook event fires, Claude Code passes a JSON payload to your handler via stdin.

// Claude Code hook event types
type HookEvent =
  | 'SessionStart'         // Session begins or resumes
  | 'UserPromptSubmit'     // User sends a prompt
  | 'PreToolUse'           // Before tool execution
  | 'PostToolUse'          // After successful tool execution
  | 'PostToolUseFailure'   // After failed tool execution
  | 'PermissionRequest'    // Permission dialog about to show
  | 'Notification'         // Agent sends a notification
  | 'Stop'                 // Agent finishes responding
  | 'StopFailure'          // Turn ends due to API error
  | 'SubagentStart'        // Subagent spawned
  | 'SubagentStop'         // Subagent finishes
  | 'TaskCreated'          // Agent team task created
  | 'TaskCompleted'        // Agent team task completed
  | 'TeammateIdle'         // Team member about to go idle
  | 'SessionEnd';          // Session terminates

// Common JSON payload (received via stdin)
interface HookPayload {
  session_id: string;
  transcript_path: string;
  cwd: string;
  permission_mode: 'default' | 'plan' | 'acceptEdits' | 'auto' | 'dontAsk' | 'bypassPermissions';
  hook_event_name: HookEvent;
  agent_id?: string;        // Present in subagent hooks
  agent_type?: string;      // Present in subagent hooks
}

// Stop hook payload
interface StopPayload extends HookPayload {
  stop_hook_active: boolean;
  last_assistant_message: string;
}

// Notification hook payload
interface NotificationPayload extends HookPayload {
  message: string;
  title: string;
  notification_type: 'permission_prompt' | 'idle_prompt' | 'auth_success' | 'elicitation_dialog';
}

// StopFailure hook payload
interface StopFailurePayload extends HookPayload {
  error: 'rate_limit' | 'authentication_failed' | 'billing_error' | 'server_error' | 'max_output_tokens' | 'unknown';
  error_details: string;
  last_assistant_message: string;
}

Hook configuration lives in ~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/on-stop.sh",
            "timeout": 30
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "permission_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/on-permission.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Key insight: Hook handlers communicate back to Claude Code through exit codes and JSON on stdout. Exit 0 = success (parse stdout for JSON). Exit 2 = blocking error (stderr is fed to Claude as an error message). Any other exit code = non-blocking error (logged but ignored). This means your hook can control Claude’s behavior β€” a Stop hook returning {"decision": "block", "reason": "Tests not passing"} will tell Claude to keep going.


OSC Escape Sequences

OSC (Operating System Command) sequences are the universal mechanism for terminal-to-emulator communication. Three standards matter for notifications:

// OSC notification protocols
interface OSCNotification {
  // OSC 9 β€” iTerm2 style (simplest, widest support)
  // Format: \e]9;{body}\a
  osc9: {
    body: string;
  };

  // OSC 777 β€” rxvt-unicode style (title + body)
  // Format: \e]777;notify;{title};{body}\a
  osc777: {
    title: string;
    body: string;
  };

  // OSC 99 β€” Kitty style (extensible, with focus-on-click)
  // Format: \e]99;i=1:d=0;{body}\a
  osc99: {
    id: number;
    done: boolean;     // d=0 means complete notification
    body: string;
    // Can optionally focus window on click
    // Can send escape code back to application
  };
}

// Terminal support matrix
const oscSupport = {
  'OSC 9':  ['Ghostty', 'iTerm2', 'Windows Terminal', 'WezTerm'],
  'OSC 777': ['Ghostty', 'urxvt', 'foot', 'contour'],
  'OSC 99': ['Kitty'],
} as const;

Helper functions for sending notifications:

#!/usr/bin/env bash

notify_osc9() {
  local body="$1"
  printf '\e]9;%s\a' "$body"
}

notify_osc777() {
  local title="$1"
  local body="$2"
  printf '\e]777;notify;%s;%s\a' "$title" "$body"
}

notify_passthrough() {
  local title="$1"
  local body="$2"

  if [ -n "$TMUX" ]; then
    # DCS passthrough β€” double the ESC, wrap in \ePtmux;...\e\\
    printf '\ePtmux;\e\e]777;notify;%s;%s\a\e\\' "$title" "$body"
  else
    printf '\e]777;notify;%s;%s\a' "$title" "$body"
  fi
}

Key insight: Ghostty supports OSC 9 and OSC 777 but not OSC 99 (Kitty’s protocol). If you are writing a notification script that needs to work across terminals, use OSC 9 as the lowest common denominator. OSC 777 adds a title field but has narrower support. Always check for tmux and wrap in DCS passthrough if $TMUX is set.


Pattern 1: Agent State in the tmux Status Bar

What it does: Shows live agent state (working/done/waiting) in the tmux status bar using file-based state tracking.

When to use it: You are running 1-8 Claude Code agents across tmux sessions and want a glanceable overview without switching panes.

This pattern is the foundation used by both tmux-agent-indicator and tmux-agent-status. Here is a minimal implementation from scratch.

Step 1: Claude Code hooks write state to files

#!/usr/bin/env bash

STATE_DIR="$HOME/.cache/agent-status"
mkdir -p "$STATE_DIR"

PAYLOAD=$(cat)

SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // "unknown"')
EVENT=$(echo "$PAYLOAD" | jq -r '.hook_event_name // "unknown"')
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // ""')

case "$EVENT" in
  UserPromptSubmit|PreToolUse)
    STATE="working"
    ;;
  Stop)
    STATE="done"
    ;;
  Notification)
    NOTIFICATION_TYPE=$(echo "$PAYLOAD" | jq -r '.notification_type // ""')
    case "$NOTIFICATION_TYPE" in
      permission_prompt) STATE="waiting" ;;
      idle_prompt)       STATE="done" ;;
      *)                 STATE="working" ;;
    esac
    ;;
  StopFailure)
    STATE="error"
    ERROR_TYPE=$(echo "$PAYLOAD" | jq -r '.error // "unknown"')
    ;;
  *)
    STATE="working"
    ;;
esac

if [ -n "$TMUX" ]; then
  TMUX_SESSION=$(tmux display-message -p '#{session_name}')
else
  TMUX_SESSION="$SESSION_ID"
fi

STATE_FILE="$STATE_DIR/${TMUX_SESSION}.json"
cat > "$STATE_FILE" <<EOF
{
  "state": "$STATE",
  "event": "$EVENT",
  "session": "$TMUX_SESSION",
  "cwd": "$CWD",
  "updated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
  "error": "${ERROR_TYPE:-}"
}
EOF

Step 2: Configure Claude Code hooks

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [{
          "type": "command",
          "command": "~/.claude/hooks/agent-state.sh",
          "timeout": 5
        }]
      }
    ],
    "Stop": [
      {
        "hooks": [{
          "type": "command",
          "command": "~/.claude/hooks/agent-state.sh",
          "timeout": 5
        }]
      }
    ],
    "Notification": [
      {
        "hooks": [{
          "type": "command",
          "command": "~/.claude/hooks/agent-state.sh",
          "timeout": 5
        }]
      }
    ],
    "StopFailure": [
      {
        "hooks": [{
          "type": "command",
          "command": "~/.claude/hooks/agent-state.sh",
          "timeout": 5
        }]
      }
    ]
  }
}

Step 3: tmux status bar script reads state files

#!/usr/bin/env bash

STATE_DIR="$HOME/.cache/agent-status"

working=0
done_count=0
waiting=0
errored=0

if [ -d "$STATE_DIR" ]; then
  for f in "$STATE_DIR"/*.json; do
    [ -f "$f" ] || continue

    state=$(jq -r '.state' "$f" 2>/dev/null)
    updated=$(jq -r '.updated' "$f" 2>/dev/null)

    # Skip stale entries (older than 1 hour)
    if [ -n "$updated" ]; then
      updated_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$updated" +%s 2>/dev/null || echo 0)
      now_epoch=$(date +%s)
      age=$(( now_epoch - updated_epoch ))
      if [ "$age" -gt 3600 ]; then
        continue
      fi
    fi

    case "$state" in
      working) working=$((working + 1)) ;;
      done)    done_count=$((done_count + 1)) ;;
      waiting) waiting=$((waiting + 1)) ;;
      error)   errored=$((errored + 1)) ;;
    esac
  done
fi

parts=()
[ "$working" -gt 0 ]    && parts+=("#[fg=blue]⚑${working} working#[default]")
[ "$waiting" -gt 0 ]    && parts+=("#[fg=yellow]⏳${waiting} waiting#[default]")
[ "$done_count" -gt 0 ] && parts+=("#[fg=green]βœ“${done_count} done#[default]")
[ "$errored" -gt 0 ]    && parts+=("#[fg=red]βœ—${errored} error#[default]")

if [ ${#parts[@]} -eq 0 ]; then
  echo "no agents"
else
  IFS='|'
  echo "${parts[*]}"
fi

Step 4: tmux.conf wiring

set -g status-interval 5
set -g status-right-length 80
set -g status-right '#(~/.tmux/scripts/agent-status.sh) | %H:%M'

set -g allow-passthrough on

set -g focus-events on

Gotchas:


Pattern 2: Desktop Notifications via Ghostty OSC 9

What it does: Sends macOS desktop notifications when an agent finishes, needs permission, or errors out.

When to use it: You are working in a different application (browser, editor) and want to be pulled back to the terminal only when an agent needs attention.

#!/usr/bin/env bash

PAYLOAD=$(cat)
EVENT=$(echo "$PAYLOAD" | jq -r '.hook_event_name')
CWD=$(echo "$PAYLOAD" | jq -r '.cwd')
PROJECT=$(basename "$CWD")

send_notification() {
  local title="$1"
  local body="$2"

  if [ -n "$TMUX" ]; then
    # DCS passthrough for tmux
    printf '\ePtmux;\e\e]777;notify;%s;%s\a\e\\' "$title" "$body"
  else
    printf '\e]777;notify;%s;%s\a' "$title" "$body"
  fi
}

case "$EVENT" in
  Stop)
    LAST_MSG=$(echo "$PAYLOAD" | jq -r '.last_assistant_message // ""' | head -c 200)
    send_notification "Claude Code [$PROJECT]" "Task complete: ${LAST_MSG:0:100}"
    ;;
  Notification)
    MSG=$(echo "$PAYLOAD" | jq -r '.message // "Needs attention"')
    NTYPE=$(echo "$PAYLOAD" | jq -r '.notification_type // ""')
    case "$NTYPE" in
      permission_prompt)
        send_notification "Claude Code [$PROJECT]" "Permission needed: $MSG"
        ;;
      idle_prompt)
        send_notification "Claude Code [$PROJECT]" "Waiting for input"
        ;;
    esac
    ;;
  StopFailure)
    ERROR=$(echo "$PAYLOAD" | jq -r '.error // "unknown"')
    DETAILS=$(echo "$PAYLOAD" | jq -r '.error_details // ""' | head -c 100)
    send_notification "Claude Code ERROR [$PROJECT]" "$ERROR: $DETAILS"
    ;;
esac

Add this alongside the state hook in ~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/agent-state.sh",
            "timeout": 5
          },
          {
            "type": "command",
            "command": "~/.claude/hooks/notify-desktop.sh",
            "timeout": 5
          }
        ]
      }
    ],
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/agent-state.sh",
            "timeout": 5
          },
          {
            "type": "command",
            "command": "~/.claude/hooks/notify-desktop.sh",
            "timeout": 5
          }
        ]
      }
    ],
    "StopFailure": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/agent-state.sh",
            "timeout": 5
          },
          {
            "type": "command",
            "command": "~/.claude/hooks/notify-desktop.sh",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

Gotchas:


Pattern 3: Two-Line AI Summary Status Bar

What it does: Generates a live, AI-written summary of what each agent session is doing and displays it in a two-line tmux status bar.

When to use it: You are running long-running agents and want more context than β€œworking/done” β€” you want to know what they are working on.

This pattern was pioneered by Quickchat AI. Here is a clean implementation.

The summarization hook

#!/usr/bin/env bash

PAYLOAD=$(cat)
TRANSCRIPT=$(echo "$PAYLOAD" | jq -r '.transcript_path')
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id')

if [ -n "$TMUX" ]; then
  SESSION_NAME=$(tmux display-message -p '#{session_name}')
else
  SESSION_NAME="$SESSION_ID"
fi

SUMMARY_DIR="/tmp/claude-summaries"
mkdir -p "$SUMMARY_DIR"
SUMMARY_FILE="$SUMMARY_DIR/${SESSION_NAME}.txt"
PREV_SUMMARY=""
[ -f "$SUMMARY_FILE" ] && PREV_SUMMARY=$(cat "$SUMMARY_FILE")

CONTEXT=$(jq -r '
  select(.type == "message") |
  if .role == "user" then
    if (.content | type) == "string" then
      "USER: " + (.content | .[0:300])
    else
      empty
    end
  elif .role == "assistant" then
    if (.content | type) == "array" then
      .content[] | select(.type == "text") | "ASSISTANT: " + (.text | .[0:300])
    else
      empty
    end
  else
    empty
  end
' "$TRANSCRIPT" 2>/dev/null | tail -8)

[ -z "$CONTEXT" ] && exit 0

PROMPT="You are a status line generator for a developer dashboard.

Given the conversation below, produce a factual, consolidated description of the session's core task.
Lead with the main goal. Follow with 1-2 sentences on current progress. Maximum 2-3 sentences total.
Do not use markdown. Do not use quotes. Plain text only. Keep under 200 characters.

Previous status line (keep first sentence stable if goal unchanged):
${PREV_SUMMARY}

Recent conversation:
${CONTEXT}"

SUMMARY=$(env -u CLAUDECODE claude -p --model haiku "$PROMPT" 2>/dev/null)

if [ -n "$SUMMARY" ]; then
  echo "$SUMMARY" > "$SUMMARY_FILE"
fi

The status bar renderer

#!/usr/bin/env bash

SESSION_NAME="$1"
SUMMARY_FILE="/tmp/claude-summaries/${SESSION_NAME}.txt"

if [ -f "$SUMMARY_FILE" ]; then
  # Get terminal width for wrapping
  WIDTH=$(tmux display-message -p '#{client_width}' 2>/dev/null || echo 120)
  MAX_LEN=$(( WIDTH * 6 / 10 ))  # Use 60% of width

  SUMMARY=$(cat "$SUMMARY_FILE")

  # Truncate at word boundary
  if [ ${#SUMMARY} -gt "$MAX_LEN" ]; then
    SUMMARY="${SUMMARY:0:$MAX_LEN}"
    SUMMARY="${SUMMARY% *}..."
  fi

  echo "$SUMMARY"
else
  echo "No summary yet"
fi

tmux.conf for two-line status

set -g status 2

set -g status-format[0] '#[align=left,fg=cyan]#(~/.tmux/scripts/summary-line.sh "#{session_name}")#[align=right,fg=white]#{session_name} #[fg=gray]%H:%M'

set -g status-format[1] '#[align=left]#(~/.tmux/scripts/agent-status.sh)#[align=right,fg=magenta]#(cd #{pane_current_path} && git branch --show-current 2>/dev/null || echo "no git")'

set -g status-interval 5
set -g status-right-length 120

Hook configuration

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/summarize.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

Gotchas:


Pattern 4: Notch HUD Fed by Agent Hooks

What it does: Writes agent state to ~/.atlas/status.json, which a macOS notch HUD app reads via file watching. The HUD displays agent state in the MacBook notch area.

When to use it: You want a persistent, always-visible agent dashboard that is visible regardless of which application is in the foreground.

This pattern uses the architecture from the Atlas HUD, a macOS app that watches ~/.atlas/status.json using DispatchSource.makeFileSystemObjectSource (kernel-level file events, no polling).

The status file format

// ~/.atlas/status.json schema
interface AtlasStatus {
  status: 'green' | 'yellow' | 'red';   // Severity β€” drives HUD layout
  source: string;                         // Which system wrote this
  message: string;                        // Shown in hover panel
  banner?: string;                        // Marquee text in the notch
  bannerStyle?: 'scroll' | 'typewriter' | 'flash' | 'slide' | 'split-flap';
  updated: string;                        // ISO timestamp
  details: string[];                      // Expandable detail lines
  slots?: Record<string, SlotData>;       // Named data slots
}

interface SlotData {
  label: string;
  value: string;
  style?: 'normal' | 'warning' | 'critical';
}

The HUD app uses a severity-driven layout:

SeverityHUD StateDescription
greenCollapsedJust dots flanking the notch. All agents nominal.
yellowExpanded (small)200pt left ear. An agent needs attention.
redExpanded (large)380pt left ear. Error or urgent state.

The hook that writes to the HUD

#!/usr/bin/env bash

STATE_DIR="$HOME/.cache/agent-status"
STATUS_FILE="$HOME/.atlas/status.json"

mkdir -p "$(dirname "$STATUS_FILE")"

PAYLOAD=$(cat)
EVENT=$(echo "$PAYLOAD" | jq -r '.hook_event_name')
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id')
CWD=$(echo "$PAYLOAD" | jq -r '.cwd')


working=0
done_count=0
waiting=0
errored=0
details=()
slots="{}"

for f in "$STATE_DIR"/*.json; do
  [ -f "$f" ] || continue

  state=$(jq -r '.state' "$f" 2>/dev/null)
  session=$(jq -r '.session' "$f" 2>/dev/null)
  cwd=$(jq -r '.cwd' "$f" 2>/dev/null)
  project=$(basename "$cwd" 2>/dev/null)

  case "$state" in
    working) working=$((working + 1)); details+=("⚑ $session ($project): working") ;;
    done)    done_count=$((done_count + 1)); details+=("βœ“ $session ($project): done") ;;
    waiting) waiting=$((waiting + 1)); details+=("⏳ $session ($project): needs input") ;;
    error)   errored=$((errored + 1)); details+=("βœ— $session ($project): error") ;;
  esac
done

total=$((working + done_count + waiting + errored))

if [ "$errored" -gt 0 ] || [ "$waiting" -gt 0 ]; then
  if [ "$errored" -gt 0 ]; then
    severity="red"
    message="$errored agent(s) in error state"
    banner="ERROR: Check agent sessions"
    banner_style="flash"
  else
    severity="yellow"
    message="$waiting agent(s) waiting for input"
    banner="$waiting agent(s) need attention"
    banner_style="typewriter"
  fi
elif [ "$working" -gt 0 ]; then
  severity="green"
  message="$working agent(s) working, $done_count done"
  banner=""
  banner_style=""
else
  severity="green"
  message="All agents idle"
  banner=""
  banner_style=""
fi

DETAILS_JSON=$(printf '%s\n' "${details[@]}" | jq -R . | jq -s .)

TEMP_FILE=$(mktemp)
cat > "$TEMP_FILE" <<EOF
{
  "status": "$severity",
  "source": "claude-hooks",
  "message": "$message",
  "banner": $([ -n "$banner" ] && echo "\"$banner\"" || echo "null"),
  "bannerStyle": $([ -n "$banner_style" ] && echo "\"$banner_style\"" || echo "null"),
  "updated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
  "details": $DETAILS_JSON,
  "slots": {
    "agents": {"label": "Active", "value": "$total"},
    "working": {"label": "Working", "value": "$working"},
    "waiting": {"label": "Waiting", "value": "$waiting", "style": $([ "$waiting" -gt 0 ] && echo '"warning"' || echo '"normal"')},
    "errors": {"label": "Errors", "value": "$errored", "style": $([ "$errored" -gt 0 ] && echo '"critical"' || echo '"normal"')}
  }
}
EOF
mv "$TEMP_FILE" "$STATUS_FILE"

How the HUD reads it (Swift, from AtlasHUD)

// StatusWatcher.swift β€” file watcher using GCD DispatchSource
@Observable
class StatusWatcher {
    static let shared = StatusWatcher()

    var currentStatus: AtlasStatus = AtlasStatus(
        status: "green", source: "jane",
        message: "Connecting...", updated: "", details: []
    )

    private var statusMonitor: DispatchSourceFileSystemObject?
    private let statusPath = NSString("~/.atlas/status.json").expandingTildeInPath

    func startWatching() {
        readStatus()
        // Watch for file writes β€” kernel-level, no polling
        let fd = open(statusPath, O_EVTONLY)
        guard fd >= 0 else { return }
        let source = DispatchSource.makeFileSystemObjectSource(
            fileDescriptor: fd,
            eventMask: [.write, .rename],
            queue: .main
        )
        source.setEventHandler { [weak self] in self?.readStatus() }
        source.setCancelHandler { close(fd) }
        source.resume()
        statusMonitor = source
    }

    private func readStatus() {
        guard let data = FileManager.default.contents(atPath: statusPath),
              let status = try? JSONDecoder().decode(AtlasStatus.self, from: data) else {
            return
        }
        currentStatus = status
    }
}

The full feedback loop:

Claude Code agent (in tmux pane)
  β†’ fires Stop/Notification/StopFailure hook
    β†’ hook script writes per-session state to ~/.cache/agent-status/
    β†’ hook script aggregates all sessions β†’ writes ~/.atlas/status.json
      β†’ macOS HUD app detects file change (DispatchSource, ~instant)
        β†’ HUD updates: notch expands, banner animates, severity colors change

Key insight: The file-based approach is the right abstraction. It decouples the agent (which runs in a terminal) from the HUD (which is a native macOS app). No sockets, no HTTP servers, no IPC complexity. The kernel’s file event system (kqueue on macOS, inotify on Linux) makes it effectively instant. The HUD app does not poll β€” it reacts to file changes.

Gotchas:


Pattern 5: Ghostty AppleScript Automation Layouts

What it does: Uses Ghostty’s AppleScript API to create multi-pane agent layouts programmatically.

When to use it: You want to spin up a consistent agent workspace with one command β€” four panes, each running an agent in a specific directory.

-- agent-layout.applescript
-- Creates a 2x2 grid of agent sessions in Ghostty

tell application "Ghostty"
    -- Create a new window with specific config
    set cfg to new surface configuration
    set initial working directory of cfg to "/Users/admin/Work/project-a"

    set win to new window with cfg
    set mainTab to selected tab of win

    -- Get the initial terminal
    set t1 to focused terminal of mainTab

    -- Split right for second agent
    split t1 direction right
    set t2 to focused terminal of mainTab

    -- Split t1 down for third agent
    focus t1
    split t1 direction down
    set t3 to focused terminal of mainTab

    -- Split t2 down for fourth agent
    focus t2
    split t2 direction down
    set t4 to focused terminal of mainTab

    -- Start agents in each pane
    input text "cd /Users/admin/Work/project-a && claude\n" to t1
    input text "cd /Users/admin/Work/project-b && claude\n" to t2
    input text "cd /Users/admin/Work/project-c && claude\n" to t3
    input text "cd /Users/admin/Work/project-d && claude\n" to t4

    -- Name each pane for identification
    input text "/clear\n" to t1
    input text "/clear\n" to t2
    input text "/clear\n" to t3
    input text "/clear\n" to t4
end tell

Run it from the command line:

osascript agent-layout.applescript

Or wrap it in a shell function:

#!/usr/bin/env bash

PROJECTS=("$@")

if [ ${#PROJECTS[@]} -lt 1 ]; then
  echo "Usage: agent-workspace.sh <project-dir> [project-dir] ..."
  exit 1
fi

SCRIPT='tell application "Ghostty"
  set cfg to new surface configuration
  set initial working directory of cfg to "'"${PROJECTS[0]}"'"
  set win to new window with cfg
  set mainTab to selected tab of win
  set t1 to focused terminal of mainTab
  input text "claude\n" to t1'

for i in $(seq 1 $((${#PROJECTS[@]} - 1))); do
  dir="${PROJECTS[$i]}"
  if [ $((i % 2)) -eq 1 ]; then
    direction="right"
  else
    direction="down"
  fi
  SCRIPT+="
  split t1 direction $direction
  set t$((i + 1)) to focused terminal of mainTab
  input text \"cd $dir && claude\n\" to t$((i + 1))"
done

SCRIPT+='
end tell'

osascript -e "$SCRIPT"

Gotchas:

Key insight: Ghostty AppleScript is for initial setup β€” creating layouts, launching agents, and one-time commands. For ongoing monitoring, use tmux hooks and status bars. The two complement each other: Ghostty gives you the window management layer, tmux gives you the session persistence and state tracking layer.


Pattern 6: pipe-pane Agent Output Monitoring

What it does: Uses tmux’s pipe-pane to stream agent output to an external processor that detects events and triggers actions.

When to use it: You want to monitor agent output in real time without modifying the agent’s hook configuration. Useful for agents that do not support hooks (Codex, Aider, custom scripts).

#!/usr/bin/env bash

STATE_DIR="$HOME/.cache/agent-status"
mkdir -p "$STATE_DIR"

PANE_ID="$1"
SESSION_NAME="$2"

STATE_FILE="$STATE_DIR/${SESSION_NAME}.json"

write_state() {
  local state="$1"
  local detail="$2"
  cat > "$STATE_FILE" <<EOF
{
  "state": "$state",
  "event": "pipe-pane-detect",
  "session": "$SESSION_NAME",
  "detail": "$detail",
  "updated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
}

while IFS= read -r line; do
  # Strip ANSI escape codes for pattern matching
  clean=$(echo "$line" | sed 's/\x1b\[[0-9;]*m//g; s/\x1b\][^\\]*\\//g')

  # Detect common agent completion patterns
  case "$clean" in
    *"Task complete"*|*"I've finished"*|*"Done!"*|*"Changes applied"*)
      write_state "done" "$(echo "$clean" | head -c 100)"
      ;;
    *"Permission"*|*"approve"*|*"Allow"*|*"deny"*|*"Y/n"*)
      write_state "waiting" "$(echo "$clean" | head -c 100)"
      ;;
    *"Error"*|*"error"*|*"FAILED"*|*"rate limit"*|*"429"*)
      write_state "error" "$(echo "$clean" | head -c 100)"
      ;;
    *"Thinking"*|*"Working"*|*"Analyzing"*|*"Reading"*|*"Writing"*)
      write_state "working" "$(echo "$clean" | head -c 100)"
      ;;
  esac
done

Activate monitoring for a pane:

tmux pipe-pane -o "~/.tmux/scripts/monitor-agent.sh '#{pane_id}' '#{session_name}'"

bind-key M pipe-pane -o "~/.tmux/scripts/monitor-agent.sh '#{pane_id}' '#{session_name}'" \; display-message "Agent monitoring ON"
bind-key m pipe-pane \; display-message "Agent monitoring OFF"

Gotchas:


Pattern 7: Custom Ghostty Shader for Agent State

What it does: Uses a Ghostty custom shader to visually indicate agent state β€” dim unfocused panes, add a colored border based on agent state, or animate the background.

When to use it: You want visual differentiation between active and inactive agent panes without relying on tmux borders.

// ~/.config/ghostty/shaders/agent-state.glsl
// Dims unfocused terminals and adds a colored left-edge indicator
// The indicator color is controlled by setting the cursor color via escape sequences

// Ghostty provides these uniforms
uniform int iFocus;           // 0 = blurred, 1 = focused
uniform float iTime;
uniform float iTimeFocus;     // iTime when last focused
uniform vec4 iCursorColor;    // Current cursor color β€” we use this as state signal
uniform sampler2D iChannel0;  // Terminal texture
uniform vec2 iResolution;     // Terminal size in pixels

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = fragCoord / iResolution.xy;

    // Sample the terminal texture
    vec4 color = texture(iChannel0, uv);

    // Dim unfocused panes
    if (iFocus == 0) {
        color.rgb *= 0.6;  // 60% brightness when not focused
    }

    // Left edge indicator bar (4 pixels wide)
    float barWidth = 4.0 / iResolution.x;
    if (uv.x < barWidth) {
        // Use cursor color as the indicator
        // Hook scripts set cursor color to signal state:
        //   Green (#00ff00) = done
        //   Yellow (#ffff00) = waiting
        //   Blue (#0088ff) = working
        //   Red (#ff0000) = error
        color = iCursorColor;

        // Pulse animation for non-green states
        if (iCursorColor.g < 0.9 || iCursorColor.r > 0.1) {
            float pulse = 0.7 + 0.3 * sin(iTime * 3.0);
            color.rgb *= pulse;
        }
    }

    fragColor = color;
}

Enable the shader in Ghostty config:

custom-shader = ~/.config/ghostty/shaders/agent-state.glsl
custom-shader-animation = always

Set cursor color from a hook to signal state:

#!/usr/bin/env bash

PAYLOAD=$(cat)
EVENT=$(echo "$PAYLOAD" | jq -r '.hook_event_name')

set_cursor_color() {
  local color="$1"
  # OSC 12 sets cursor color
  if [ -n "$TMUX" ]; then
    printf '\ePtmux;\e\e]12;%s\a\e\\' "$color"
  else
    printf '\e]12;%s\a' "$color"
  fi
}

case "$EVENT" in
  UserPromptSubmit|PreToolUse)
    set_cursor_color "#0088ff"  # Blue = working
    ;;
  Stop)
    set_cursor_color "#00ff00"  # Green = done
    ;;
  Notification)
    NTYPE=$(echo "$PAYLOAD" | jq -r '.notification_type // ""')
    case "$NTYPE" in
      permission_prompt) set_cursor_color "#ffff00" ;;  # Yellow = waiting
      *)                 set_cursor_color "#0088ff" ;;  # Blue = working
    esac
    ;;
  StopFailure)
    set_cursor_color "#ff0000"  # Red = error
    ;;
esac

Key insight: This is a creative hack, not a supported integration. Ghostty does not have an API to pass arbitrary data to shaders. The cursor color is the only dynamic value a shell script can control (via OSC 12) that is visible to the shader (via iCursorColor). It works, but it also changes the actual cursor color. If you use cursor color for other purposes, this will conflict.

Gotchas:


Example 1: tmux hook that auto-focuses a pane when its agent needs input

set-hook -g alert-activity 'run-shell "
  for f in ~/.cache/agent-status/*.json; do
    state=$(jq -r .state \"$f\" 2>/dev/null)
    session=$(jq -r .session \"$f\" 2>/dev/null)
    if [ \"$state\" = \"waiting\" ]; then
      tmux select-window -t \"$session\"
      break
    fi
  done
"'

Example 2: Ghostty AppleScript to find a terminal by working directory

-- Find and focus the terminal working on a specific project
tell application "Ghostty"
    repeat with w in windows
        repeat with t in tabs of w
            repeat with term in terminals of t
                if working directory of term contains "project-alpha" then
                    focus term
                    activate window w
                    return
                end if
            end repeat
        end repeat
    end repeat
end tell

Example 3: Send a tmux notification with a sound

#!/usr/bin/env bash
PAYLOAD=$(cat)
EVENT=$(echo "$PAYLOAD" | jq -r '.hook_event_name')

if [ "$EVENT" = "Stop" ]; then
  # Play the system "Glass" sound
  afplay /System/Library/Sounds/Glass.aiff &
  # Also update tmux display
  tmux display-message "Agent finished in #{session_name}"
fi

Example 4: tmux capture-pane to extract the last agent response

#!/usr/bin/env bash
PANE_TARGET="${1:-}"

if [ -z "$PANE_TARGET" ]; then
  echo "Usage: capture-response.sh <pane-target>"
  exit 1
fi

CONTENT=$(tmux capture-pane -t "$PANE_TARGET" -p -S -50)

RESPONSE=$(echo "$CONTENT" | awk '
  /^>/ { buf=""; collecting=1; next }
  collecting { buf = buf "\n" $0 }
  END { print buf }
')

echo "$RESPONSE"

Example 5: Reset all agent state files (cleanup script)

#!/usr/bin/env bash

STATE_DIR="$HOME/.cache/agent-status"

if [ -d "$STATE_DIR" ]; then
  rm -f "$STATE_DIR"/*.json
  echo "Cleared $(ls "$STATE_DIR" 2>/dev/null | wc -l) agent state files"
else
  echo "No state directory found"
fi

cat > ~/.atlas/status.json <<EOF
{
  "status": "green",
  "source": "manual-reset",
  "message": "All agents cleared",
  "updated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
  "details": []
}
EOF

echo "HUD reset to green"

Example 6: tmux key binding to cycle through agent panes

bind-key n run-shell '
  CURRENT=$(tmux display-message -p "#{pane_id}")
  FOUND=0
  for pane in $(tmux list-panes -a -F "#{pane_id}"); do
    if [ "$FOUND" = "1" ]; then
      tmux select-pane -t "$pane"
      exit 0
    fi
    if [ "$pane" = "$CURRENT" ]; then
      FOUND=1
    fi
  done
  # Wrap around to first pane
  FIRST=$(tmux list-panes -a -F "#{pane_id}" | head -1)
  tmux select-pane -t "$FIRST"
'

Example 7: Write context window usage to tmux status

#!/usr/bin/env bash

PAYLOAD=$(cat)
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id')

TRANSCRIPT=$(echo "$PAYLOAD" | jq -r '.transcript_path')

if [ -n "$TMUX" ]; then
  SESSION_NAME=$(tmux display-message -p '#{session_name}')
  CTX_FILE="/tmp/claude-ctx/${SESSION_NAME}.txt"
  mkdir -p /tmp/claude-ctx

  # Extract the last context percentage from transcript metadata
  CTX_PCT=$(jq -r '
    select(.type == "system" and .context_window) |
    .context_window.used_percentage // empty
  ' "$TRANSCRIPT" 2>/dev/null | tail -1)

  if [ -n "$CTX_PCT" ]; then
    echo "${CTX_PCT}%" > "$CTX_FILE"
  fi
fi

Example 8: Ghostty config for agent-friendly defaults


font-size = 13
theme = catppuccin-mocha

desktop-notifications = true
notify-on-command-finish = unfocused
notify-on-command-finish-action = notify
notify-on-command-finish-after = 10

shell-integration = detect

window-save-state = always
window-padding-x = 4
window-padding-y = 4

keybind = ctrl+a>n=new_window
keybind = ctrl+a>s=new_split:right
keybind = ctrl+a>v=new_split:down
keybind = ctrl+a>w=close_surface
keybind = ctrl+a>1=goto_tab:1
keybind = ctrl+a>2=goto_tab:2
keybind = ctrl+a>3=goto_tab:3
keybind = ctrl+a>4=goto_tab:4

keybind = ctrl+a>d=write_scrollback_file:open

Example 9: Combined hook handler (all events, one script)

#!/usr/bin/env bash

PAYLOAD=$(cat)
EVENT=$(echo "$PAYLOAD" | jq -r '.hook_event_name')

HOOKS_DIR="$HOME/.claude/hooks"

echo "$PAYLOAD" | "$HOOKS_DIR/agent-state.sh" 2>/dev/null &

echo "$PAYLOAD" | "$HOOKS_DIR/hud-status.sh" 2>/dev/null &

case "$EVENT" in
  Stop|StopFailure|Notification)
    echo "$PAYLOAD" | "$HOOKS_DIR/notify-desktop.sh" 2>/dev/null &
    ;;
esac

if [ "$EVENT" = "Stop" ]; then
  echo "$PAYLOAD" | "$HOOKS_DIR/summarize.sh" 2>/dev/null &
fi

echo "$PAYLOAD" | "$HOOKS_DIR/set-cursor-color.sh" 2>/dev/null &

wait

Configure once in settings:

{
  "hooks": {
    "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "~/.claude/hooks/unified-handler.sh", "timeout": 30}]}],
    "Stop": [{"hooks": [{"type": "command", "command": "~/.claude/hooks/unified-handler.sh", "timeout": 30}]}],
    "Notification": [{"hooks": [{"type": "command", "command": "~/.claude/hooks/unified-handler.sh", "timeout": 30}]}],
    "StopFailure": [{"hooks": [{"type": "command", "command": "~/.claude/hooks/unified-handler.sh", "timeout": 30}]}]
  }
}

Terminal Emulators for Automation

FeatureGhosttyiTerm2KittyWezTermAlacritty
Automation APIAppleScript (macOS, preview)AppleScript + Python APIRemote control protocol + kittensLua scripting APINone
tmux integrationStandard (run tmux inside)Control mode (-CC) β€” native tabsStandardStandard (mux server built-in)Standard
OSC 9 notificationsYesYesNo (uses OSC 99)YesNo
OSC 777 notificationsYesNoNoNoNo
OSC 99 notificationsNoNoYesNoNo
Custom shadersGLSL with rich uniformsNoYes (GLSL)GLSL (WebGPU)No
Shell integrationOSC 133 (detect)Proprietary + OSC 133NoOSC 133No
Focus eventsYesYesYesYesNo
GPU accelerationYes (Metal/Vulkan)MetalOpenGLWebGPU/MetalOpenGL
Config formatKey-value fileGUI + plistKey-value (kitty.conf)LuaYAML/TOML
Cross-platformmacOS, LinuxmacOS onlymacOS, Linux, BSDmacOS, Linux, WindowsmacOS, Linux, Windows, BSD
External command triggerVia keybind actionsVia scripts/triggersVia kitten @Via Lua eventsNo

Recommendations:

tmux Agent Management Tools

ToolTypeAgents SupportedKey FeatureStatus TrackingMulti-machine
tmux-agent-indicatortmux pluginClaude, Codex, OpenCode, customPane border colors + status iconsHook-based + process detectionNo
tmux-agent-statustmux pluginClaude, Codex, any custom agentSession switcher with groupingHook-based + file-basedYes (SSH)
dmuxCLI/TUI11+ agents (Claude, Codex, Gemini, etc.)Git worktree isolation per taskBuilt-inNo
NTMCLI/TUIClaude, Codex, GeminiBroadcast prompts, conflict detectionDashboardNo
claude-tmuxTUI (Rust)Claude CodePopup session manager with live previewProcess detectionNo
agent-deckTUIClaude, Gemini, OpenCode, Codex + moreTerminal session managementProcess-basedNo
agtxCLI/TUIClaude, Codex, Gemini, OpenCode, CursorKanban board + orchestrator agentSpec-drivenNo
CodemanWeb UIClaude, OpenCodeBrowser-based tmux session managerWebSocketNo
DIY (this article)ScriptsAnyFull control, no dependenciesFile-basedYes (file sync)

Recommendations:


Don’tDo InsteadWhy
Poll panes with tmux capture-pane in a loopUse Claude Code hooks to write state filesPolling wastes CPU, misses events between polls, and scales badly with pane count
Use tmux -CC control mode with GhosttyUse standard tmux inside GhosttyGhostty does not support control mode. Only iTerm2 does. Your tmux sessions will not render correctly
Send notifications without checking $TMUXAlways check for tmux and wrap in DCS passthroughRaw OSC sequences are intercepted by tmux and never reach the outer terminal
Run claude -p inside a Claude Code hook without unsetting CLAUDECODEUse env -u CLAUDECODE claude -p ...The CLAUDECODE environment variable blocks nested invocations, causing silent failures
Use pipe-pane for agents that support hooksUse hooks for hook-capable agents, pipe-pane for those that do notHook payloads have structured JSON with session ID, event type, and transcript path. pipe-pane gives you raw ANSI-cluttered output you must parse
Write the HUD status file directly (non-atomic)Write to a temp file, then mv it atomicallyThe HUD app may read a half-written file, parse it as invalid JSON, and silently show stale data
Rely on Ghostty AppleScript for session persistenceUse tmux for persistence, Ghostty AppleScript for layout setupGhostty AppleScript windows do not survive application restarts. tmux sessions persist
Set custom-shader-animation = always with many terminalsUse true (focused-only) or limit to specific profilesalways runs the shader animation loop on every terminal surface, even unfocused ones. With 8+ terminals this is measurable GPU load
Use different state file formats per agentUse a single JSON schema for all agent state filesMultiple tools reading the same state directory need a consistent format. Define it once and stick to it
Nest jq calls inside jq filtersUse a single jq pipeline with proper type checkingNested calls are slower and fail silently. Check types (type == "string", type == "array") within one pipeline
Set status-interval 1 for real-time statusUse status-interval 5 and accept ~5s latency1-second intervals run your status scripts 5x more often. For most agent workflows, 5-second updates are perfectly fine
Hardcode paths in hook scriptsUse $HOME, $TMUX, $CLAUDE_PROJECT_DIRHardcoded paths break when used by other users, in CI, or on different machines
Use OSC 99 (Kitty protocol) for cross-terminal compatibilityUse OSC 9 as the lowest common denominatorOSC 99 only works in Kitty. OSC 9 works in Ghostty, iTerm2, WezTerm, and Windows Terminal

Official Documentation

Tools and Plugins

Blog Posts and Articles

Discussions and Issues


Edit page
Share this post on:

Previous Post
Notification System Design Patterns
Next Post
Production AI Anti-Patterns