There is a small AppleScript in this repo at python/iterm_tab_names.applescript that solves a specific automation problem: find an iTerm2 tab by partial name, send a command into it, wait for the shell to come back to a prompt, and return what the command produced. It is useful when you want one process (a script, a Make target, a parent agent) to drive a long-lived terminal session in another window without losing the output.
This article documents what the script actually does, every argument pattern it accepts, and the limits to be aware of.
What it does
Given a needle string, the script walks every iTerm2 window, tab, and session, and matches the needle against the session name or profile name with a substring test. The first match wins. That tab is selected, iTerm is brought to the front, and the command is sent in two steps: first the text is typed into the session with write text theCommand newline no (no embedded newline), then after a 100ms settle a real Return key is dispatched via System Events keystroke return.
This two-step send is deliberate. A single write text with the default newline works for plain shells but is silently swallowed by TUI applications – in particular Claude Code – because iTerm2 delivers write text content as a paste, and the embedded \n is treated as a literal character in the input buffer rather than as a submit. Splitting the typed text from the Enter key gets around that.
After sending, the script polls is at shell prompt of targetSession (an iTerm2 shell-integration property) every 0.2 seconds, up to 30 seconds. When the prompt comes back, or the timeout expires, it snapshots the visible session buffer and diffs it against a snapshot taken before sending. The appended portion is what the command produced.
If iTerm2 shell integration is not installed in the target session, is at shell prompt errors out. The script catches that, falls back to a fixed 1.5-second delay, and continues.
The return value is a small header line followed by the diff:
-- sent 'ls -la' to 'DEV (-zsh)'
--
total 256
drwxr-xr-x ... files ...
If shell integration was missing, the header notes that. If the 30-second timeout expired before the prompt returned, the header notes that too.
Argument patterns
The script accepts four shapes.
No args – default behavior:
osascript python/iterm_tab_names.applescript
Needle defaults to "Claude Code". Command defaults to "commit". Useful as a one-key shortcut when you have a Claude Code tab running and you want to fire commit into it.
One arg – command only:
osascript python/iterm_tab_names.applescript "/review"
osascript python/iterm_tab_names.applescript "ls -la"
Needle stays "Claude Code". The single argument is sent verbatim. This is the original calling convention; existing scripts that pass one argument keep working.
Two args – needle and command:
osascript python/iterm_tab_names.applescript "DEV" "mix test"
osascript python/iterm_tab_names.applescript "beam.smp" "i(self())"
First argument is the substring matched against session/profile names. Second argument is the command. Use this when you have multiple terminal sessions and you want to target a specific one.
--list – enumerate without sending:
osascript python/iterm_tab_names.applescript --list
Prints every tab as [wN tM] name=... | profile=.... No command is sent. Works whether --list is the only arg or the first of two. Use this to figure out a unique needle before automating against a session.
Sample output of --list:
[w1 t1] name=⠐ Claude Code (caffeinate) | profile=DEV
[w1 t2] name=beam.smp | profile=DEV
[w1 t3] name=DEV (-zsh) | profile=DEV
[w2 t1] name=ssh | profile=Default
[w3 t2] name=root@ubuntu: ~ (-zsh) | profile=Default
What gets matched
The needle is tested against two strings per session: the session name (the title bar text iTerm2 shows) and the profile name (the iTerm2 profile assigned to the session). A match in either wins. The test is a substring contains, so partial fragments work ("DEV" matches "DEV (-zsh)", "Claude Code" matches "⠐ Claude Code (caffeinate)").
The session name changes over time as the shell sets terminal titles. The profile name is stable and assigned when the tab is created. If you want a stable target, name a dedicated profile and match on that.
Selecting a unique needle
The script picks the first match. If multiple sessions could match, the one that wins depends on traversal order (window 1 tab 1 session 1, then session 2, then window 1 tab 2, and so on). To avoid surprises, choose a needle that appears in exactly one session. Run --list first if you are not sure.
No match: how it tells you
If the needle does not match anything, the script returns:
no tab matches 'foo'. candidates:
[w1 t1] name=... | profile=...
[w1 t2] name=... | profile=...
...
So you do not need a separate --list call to debug. The error already shows you what was there to match against.
Shell-integration vs fallback delay
Two timing paths:
-
With shell integration in the target session, the script polls
is at shell promptand waits up to 30 seconds. As soon as the prompt comes back, it adds a 0.25-second settle delay (so the final line of output lands in the buffer) and snapshots. -
Without shell integration, the script catches the error from the first
is at shell promptquery, then sleeps a fixed 1.5 seconds before snapshotting.
Install iTerm2 shell integration via iTerm2 -> Install Shell Integration in the menu. The polling path captures longer commands accurately; the fallback path will truncate output from anything that takes more than 1.5 seconds.
Output diff: how the script handles edge cases
The diff is computed by checking whether the after-snapshot starts with the before-snapshot and is longer. If so, the appended portion is returned. If the buffer scrolled or shrunk during the command (long output that pushed earlier lines off the top), the script returns the full current buffer instead. This is conservative – you may see a few lines from before the command in the output, but you will not lose what the command actually produced.
Real uses
A few patterns that work well:
Driving Claude Code from a Make target. Fire commit or /review into a Claude Code tab from a make rule. The output comes back as the make rule’s output.
Running a smoke test in a long-lived BEAM session. Keep an iex running in a tab named via profile. Send MyApp.smoke_test() and capture the diff to compare against expected output.
CI dry-runs locally. Mirror the commands a CI step would run by piping them through this script to a dedicated “CI scratch” tab.
Parent agent driving child tabs. A scheduling process can dispatch commands to whichever iTerm2 tab matches a workload, in parallel with each other, without the parent needing to manage shells directly.
TUI targets and the paste-detection trap
A normal shell treats incoming \n as “execute,” so the original one-step write text worked fine for ls -la and friends. TUI applications like Claude Code, vim, htop, and similar do not. They read keystrokes through a higher-level layer that distinguishes pasted input from typed input – via bracketed paste markers (ESC[200~ and ESC[201~) when the terminal supports it, or via timing heuristics when it does not. Inside a paste, an embedded newline is a literal \n character in the input buffer, not a submit signal.
The fix is the two-step send described above: type the command with newline no, then send a real Return key via System Events keystroke return. The keystroke arrives outside any paste wrapper, so the TUI treats it as a genuine submit.
If the keystroke seems to land before the typed text is fully rendered (a fast machine, a long command), bump the delay 0.1 in the script up. 0.2s or 0.3s is usually enough.
macOS Accessibility permission
System Events keystrokes require macOS Accessibility permission for whatever process invokes osascript. When you call it from Terminal, iTerm2, a shell wrapper, or a launchd job, that process must be granted access in System Settings -> Privacy & Security -> Accessibility. The failure mode is an error like -1719 (not allowed to send keystrokes). Most setups already have this from other automation, but it is the first thing to check if the script reports the matching tab was found yet the Return key never arrived.
Limits worth knowing
- One-shot per invocation. The script does not persist a session connection. Each call walks the window list again.
-
Quote handling. The command is passed through
osascript‘s argument parsing, then through AppleScript string handling, then typed character-by-character into the session. Special characters need to be quoted/escaped for your shell as usual. -
iTerm-only. There is no Terminal.app fallback. The whole script is wrapped in
tell application "iTerm". -
No return code. The script returns the textual output of the command, not its exit status. If you need exit status, have the command print it explicitly:
command; echo "EXIT=$?". - Single Return only. The dispatched keystroke is one Return. If the target needs a different commit key (Shift+Enter, Ctrl+D, etc.), the script needs to be edited.
Where to look in the source
The script is one file, around 175 lines, at python/iterm_tab_names.applescript. The top of the file lists the four argument patterns. The matching loop sits at the start of the second tell application "iTerm" block. The two-step send is around line 115. The before/after diff logic is at the end, just before the header is composed. Read the file directly if you want to extend it – it is short enough to grok in one pass.