A companion to the Co-Builder Bootstrap for anyone running Claude Code inside Cursor as their IDE. Paste this one prompt and every terminal tab gets a status dot plus a short auto-generated name - so you can tell at a glance which sessions are working, finished, or already read.
macOS only. It wires up a rename skill, two status hooks, and a small Cursor extension that coordinate through the tab's pty.
Claude is mid-turn on this tab.
Finished, with output you have not looked at yet.
Finished and you have opened the tab - caught up.
Session wrapped via /end - safe to kill or clear.
Copy this and paste it into Claude Code running in a Cursor terminal.
Set up the Cursor terminal-tab status + auto-naming system on this machine. I'm on macOS running Claude Code inside Cursor's integrated terminal. The goal: each terminal tab shows a status dot plus a short auto-generated name. Dots mean: ⏳ Claude is working, 🔵 done with unread output, ✅ done and I've already opened the tab. Do all of the steps below. Create parent folders as needed. MERGE into existing files (especially the JSON ones) instead of overwriting them, and preserve any hooks/settings already there.
### STEP 1 - Cursor settings
In `~/Library/Application Support/Cursor/User/settings.json`, set:
```
"terminal.integrated.tabs.title": "${sequence}",
"explorer.compactFolders": false
```
The first key drives the tab titles. The second disables Cursor's compact-folder behavior so nested single-child folders (e.g. `a/b/c`) show as a full expandable tree instead of being collapsed onto one row. Merge into the existing JSON.
### STEP 2 - Claude env
In `~/.claude/settings.json`, under `"env"`, add:
```
"CLAUDE_CODE_DISABLE_TERMINAL_TITLE": "1"
```
This stops Claude Code's built-in auto-title from clobbering our custom names. Merge, don't overwrite.
### STEP 3 - Create `~/.claude/skills/rename/set-title.sh` and chmod +x it. Exact contents:
```bash
#!/bin/bash
#h <title> - set the BASE name for THIS Cursor terminal tab and render it.
# Stores the base name (keyed by the pty device) so the status hooks (tab-state.sh) can
# re-prefix it with working/done markers, then writes the title to the pty. This runs
# while Claude is mid-turn, so it renders with the "working" prefix.
#
# State lives in ~/.claude/tab-state/ - NOT /tmp. macOS purges /tmp files not accessed
# in ~3 days and on every reboot, which silently killed the hooks for long-lived tabs.
# Keyed-by-pty state is meaningless across reboots (ttysNNN numbers are recycled), so a
# boot guard wipes stale entries once per boot instead.
STATE_DIR="$HOME/.claude/tab-state"
mkdir -p "$STATE_DIR"
# boot guard: on the first run after a reboot, clear old pty-keyed state so a recycled
# ttysNNN doesn't inherit a dead session's name. These files are regenerable runtime
# state written only by this script - safe to remove.
boot=$(sysctl -n kern.boottime 2>/dev/null | sed 's/.*sec = \([0-9][0-9]*\).*/\1/')
if [ -n "$boot" ] && [ "$(cat "$STATE_DIR/boot-id" 2>/dev/null)" != "$boot" ]; then
rm -f "$STATE_DIR"/*.name "$STATE_DIR"/*.state
printf '%s' "$boot" > "$STATE_DIR/boot-id"
fi
title="$*"
if [ -z "$title" ]; then
echo "usage: set-title.sh <title>" >&2
exit 1
fi
# resolve the owning pty by walking the parent process tree for the first ttysNNN
pid=$$; dev=""
while [ -n "$pid" ] && [ "$pid" != "1" ] && [ "$pid" != "0" ]; do
t=$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')
case "$t" in ttys*) dev="/dev/$t"; break;; esac
pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
done
if [ -z "$dev" ] || [ ! -w "$dev" ]; then
echo "could not find a writable pty for this session (found: ${dev:-none})" >&2
exit 1
fi
# persist the base name keyed by the pty device so the status hooks can read it
key=$(printf '%s' "$dev" | tr -c 'A-Za-z0-9' '_')
printf '%s' "$title" > "$STATE_DIR/$key.name"
printf 'working' > "$STATE_DIR/$key.state"
# render now with the working prefix (set-title runs while Claude is working)
printf '\033]0;%s %s\007' "⏳" "$title" > "$dev"
echo "tab renamed to: $title (via $dev)"
```
### STEP 4 - Create `~/.claude/skills/rename/tab-state.sh` and chmod +x it. Exact contents:
```bash
#!/bin/bash
# tab-state.sh <working|done|end> - repaint THIS Cursor tab's title with a status prefix,
# keeping the base name set by set-title.sh. Called by the UserPromptSubmit (working)
# and Stop (done) hooks, and optionally by a session-end command (end). Silent and
# best-effort: any problem just exits 0.
#
# State lives in ~/.claude/tab-state/ (not /tmp - see set-title.sh for why).
state="$1"
STATE_DIR="$HOME/.claude/tab-state"
mkdir -p "$STATE_DIR" 2>/dev/null
# boot guard: pty-keyed state doesn't survive a reboot (ttysNNN numbers are recycled).
# First hook fire after a reboot clears the old entries. Regenerable runtime state only.
boot=$(sysctl -n kern.boottime 2>/dev/null | sed 's/.*sec = \([0-9][0-9]*\).*/\1/')
if [ -n "$boot" ] && [ "$(cat "$STATE_DIR/boot-id" 2>/dev/null)" != "$boot" ]; then
rm -f "$STATE_DIR"/*.name "$STATE_DIR"/*.state
printf '%s' "$boot" > "$STATE_DIR/boot-id"
fi
# resolve the owning pty by walking the parent process tree for the first ttysNNN
pid=$$; dev=""
while [ -n "$pid" ] && [ "$pid" != "1" ] && [ "$pid" != "0" ]; do
t=$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')
case "$t" in ttys*) dev="/dev/$t"; break;; esac
pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
done
[ -z "$dev" ] && exit 0
[ -w "$dev" ] || exit 0
key=$(printf '%s' "$dev" | tr -c 'A-Za-z0-9' '_')
namefile="$STATE_DIR/$key.name"
[ -f "$namefile" ] || exit 0 # no base name yet -> nothing to prefix
base=$(cat "$namefile")
[ -z "$base" ] && exit 0
case "$state" in
working) prefix="⏳"; printf 'working' > "$STATE_DIR/$key.state";;
done)
# The Stop hook fires 'done' after EVERY turn - including the turn that set 'end'.
# Without this guard it would clobber ⚫ back to 🔵 immediately. Never downgrade an
# ended tab; ⚫ stays until the next user message (working).
[ "$(cat "$STATE_DIR/$key.state" 2>/dev/null)" = "end" ] && exit 0
prefix="🔵"; printf 'done' > "$STATE_DIR/$key.state";;
end) prefix="⚫"; printf 'end' > "$STATE_DIR/$key.state";;
*) exit 0;;
esac
printf '\033]0;%s %s\007' "$prefix" "$base" > "$dev"
exit 0
```
### STEP 5 - Create `~/.claude/skills/rename/SKILL.md` with exact contents:
```markdown
---
name: rename
description: Rename the current Cursor terminal tab. Invoked when the user types /rename [name] to set this chat's tab title. With a name, use it verbatim; with no name, generate a short (~3-word) summary of the conversation and use that.
---
# rename
Sets the title of the Cursor terminal tab for THIS Claude session by writing an OSC
escape sequence to the pty device the claude process owns. (The generic /dev/tty is
unreachable from a tool subprocess, so the helper script walks the parent process tree
to the real ttysNNN device.)
## Steps
1. Decide the title:
- If the user typed text after /rename, use that text verbatim.
- If nothing was provided, write your own concise title (~3 words max) that captures
what this conversation is about.
2. Run the helper, passing the title as one quoted argument:
bash ~/.claude/skills/rename/set-title.sh "<title>"
3. Tell the user what the tab is now named (one short line).
## Rules
- Keep titles short - aim for 1 to 4 words. Long titles get truncated in the tab column.
- No quotes or emoji unless the user asked for them.
## If the title reverts after a few seconds
That means Claude Code's own auto-title feature is still re-stamping over it. Confirm
CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 is set in ~/.claude/settings.json under "env", and
note that env changes only take effect after the claude session is restarted.
```
### STEP 6 - Hooks
In `~/.claude/settings.json`, merge in these hooks (keep any existing ones intact). The UserPromptSubmit hook repaints the tab to ⏳ working and injects a one-time-per-session reminder to auto-name the tab; the Stop hook repaints it to 🔵 done:
```json
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "SID=$(jq -r '.session_id // \"nosession\"'); M=\"/tmp/.claude-tab-named-$SID\"; if [ ! -f \"$M\" ]; then touch \"$M\"; printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"TAB TITLE REMINDER: if you have not yet named this Cursor terminal tab this session, do it now silently by running bash ~/.claude/skills/rename/set-title.sh with your best concise 1-4 word title for this chat. Refine over the next few messages, then settle. Do not announce it.\"}}'; fi"
},
{
"type": "command",
"command": "bash \"$HOME/.claude/skills/rename/tab-state.sh\" working"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash \"$HOME/.claude/skills/rename/tab-state.sh\" done"
}
]
}
]
}
```
### STEP 7 - Read-state Cursor extension (gives the ✅ "I've opened it" dot)
Create `~/.cursor/extensions/justin.cursor-tab-status-0.0.1/package.json` with exact contents:
```json
{
"name": "cursor-tab-status",
"displayName": "Cursor Tab Status (Claude)",
"description": "Clears the Claude 'unread output' marker on a terminal tab when you focus/open it.",
"version": "0.0.1",
"publisher": "justin",
"engines": {
"vscode": "^1.75.0"
},
"main": "./extension.js",
"activationEvents": [
"onStartupFinished"
],
"contributes": {}
}
```
And create `~/.cursor/extensions/justin.cursor-tab-status-0.0.1/extension.js` with exact contents:
```javascript
// Cursor Tab Status (Claude)
// When you focus/open a terminal tab whose Claude output is "done/unread" (blue dot),
// mark it read with a checkmark prefix to mean "you've seen it".
// Coordinates with ~/.claude/skills/rename/*.sh via ~/.claude/tab-state/<dev>.{name,state}.
// (State lives outside /tmp: macOS purges /tmp after ~3 days idle + on reboot,
// which silently kills long-lived tabs.)
const vscode = require('vscode');
const fs = require('fs');
const os = require('os');
const path = require('path');
const cp = require('child_process');
const STATE_DIR = path.join(os.homedir(), '.claude', 'tab-state');
try { fs.mkdirSync(STATE_DIR, { recursive: true }); } catch (e) { /* ignore */ }
const HEARTBEAT = path.join(STATE_DIR, 'ext.alive');
function beat() {
// refreshed on every poll tick (not just activation), so a stale/missing
// heartbeat reliably means "extension not running", not "file got cleaned up"
try { fs.writeFileSync(HEARTBEAT, String(Date.now())); } catch (e) { /* ignore */ }
}
function devKey(dev) {
// must match the bash: tr -c 'A-Za-z0-9' '_'
return dev.replace(/[^A-Za-z0-9]/g, '_');
}
function ttyForPid(pid) {
try {
const out = cp.execSync(`ps -o tty= -p ${pid}`, { encoding: 'utf8' }).trim();
if (out.startsWith('ttys')) return '/dev/' + out;
} catch (e) { /* ignore */ }
return null;
}
function markRead(dev) {
const key = devKey(dev);
const statefile = path.join(STATE_DIR, `${key}.state`);
const namefile = path.join(STATE_DIR, `${key}.name`);
let state;
try { state = fs.readFileSync(statefile, 'utf8').trim(); } catch (e) { return; }
if (state !== 'done') return; // only clear an unread/done tab
let name = '';
try { name = fs.readFileSync(namefile, 'utf8'); } catch (e) { /* ignore */ }
try {
fs.writeFileSync(statefile, 'read');
fs.writeFileSync(dev, `\x1b]0;✅ ${name}\x07`); // checkmark = read/caught up
} catch (e) { /* ignore */ }
}
async function checkActive(terminal) {
if (!terminal) return;
try {
const pid = await terminal.processId;
if (!pid) return;
const dev = ttyForPid(pid);
if (dev) markRead(dev);
} catch (e) { /* ignore */ }
}
function activate(context) {
beat();
// you clicked into / switched to a terminal tab
context.subscriptions.push(
vscode.window.onDidChangeActiveTerminal((t) => checkActive(t))
);
// the Cursor window regained focus -> re-check whatever terminal is active
context.subscriptions.push(
vscode.window.onDidChangeWindowState((st) => {
if (st.focused) checkActive(vscode.window.activeTerminal);
})
);
// "finished while you were already looking at it": watch the state files; if one
// flips to done while this window is focused, clear the active terminal's marker
try {
const watcher = fs.watch(STATE_DIR, (event, filename) => {
if (!filename) return;
if (!filename.endsWith('.state')) return;
if (!vscode.window.state.focused) return;
checkActive(vscode.window.activeTerminal);
});
context.subscriptions.push({ dispose: () => watcher.close() });
} catch (e) { /* ignore */ }
// polling fallback: fs.watch can miss file modifies on macOS, so every 1.5s
// refresh the heartbeat and, if this window is focused, re-check the active
// terminal. Cheap (one tiny write + one ps + 2 reads).
const poll = setInterval(() => {
beat();
if (vscode.window.state.focused) checkActive(vscode.window.activeTerminal);
}, 1500);
context.subscriptions.push({ dispose: () => clearInterval(poll) });
// initial sweep on activation
checkActive(vscode.window.activeTerminal);
}
function deactivate() {}
module.exports = { activate, deactivate };
```
Cursor auto-registers an extension folder on the next window reload, so just creating that folder is usually enough. If the ✅ dot never appears after reloading, add an entry for it to `~/.cursor/extensions/extensions.json` (an array of extension entries) keyed by `identifier.id` "justin.cursor-tab-status", and dedupe so it appears only once.
Liveness check: the extension refreshes a heartbeat file at `~/.claude/tab-state/ext.alive` every ~1.5 seconds while running. If that file is missing or stale, the extension is not loaded - reload the window and check again.
### STEP 8 - Auto-naming rule
Append this rule to `~/.claude/CLAUDE.md` so tabs name themselves with no /rename needed:
> After the very first user message, silently run `bash ~/.claude/skills/rename/set-title.sh "<title>"` with a concise 1-4 word title for the chat, even if it's a rough guess. Refine it over the next few messages as the topic becomes clear, then settle and stop renaming. Keep titles 1-4 words, no quotes or emoji. Do it silently, don't announce it. Leave manually-renamed tabs alone.
### STEP 9 - End-state marker (optional, pairs with a session-end command)
If you have a session-end command (for example the `/end` command from the Co-Builder
Bootstrap), add this as its final action so the tab flips to ⚫ once a session is wrapped
and ready to kill/clear:
```
bash ~/.claude/skills/rename/tab-state.sh end
```
The read-state extension only repaints `done` tabs, so ⚫ persists until your next message
flips it back to ⏳. Skip this step if you don't use a session-end command.
### FINALLY - tell me to do these two activation steps, because the changes don't load live:
1. Quit and relaunch `claude` in each terminal tab (loads the env var and the hooks).
2. In Cursor, open the command palette (Cmd+Shift+P) and run "Developer: Reload Window" (loads the `${sequence}` setting and the read-state extension).
Quick test after that: send any message in a tab. While Claude works the tab shows the ⏳ working dot then the name; when it finishes it shows the 🔵 done dot then the name; click into the tab and it should flip to the ✅ read dot then the name.
### TROUBLESHOOTING - set-title.sh fails with "could not find a writable pty (found: none)"
Recent Claude Code versions run Bash tool commands inside an OS sandbox by default, and
the sandbox breaks set-title.sh three separate ways: `ps` is blocked (so the parent-tree
pty walk finds nothing), /dev/ttysNNN is not write-allowed, and ~/.claude/tab-state/ is
outside the write allowlist. The hooks keep working (hooks run outside the tool sandbox),
but without a name file from set-title.sh they no-op - so new tabs never get named or
dotted.
Fix: exclude the helper from the sandbox. Merge this into `~/.claude/settings.json`:
```json
"sandbox": {
"excludedCommands": [
"bash ~/.claude/skills/rename/set-title.sh *"
]
}
```
Two gotchas:
- The exclusion matches the exact invocation form, so the script must be called exactly as
`bash ~/.claude/skills/rename/set-title.sh "<title>"` - nothing appended after the quoted
title. Even a trailing `2>&1` defeats the match and the call runs sandboxed (and fails)
again.
- tab-state.sh needs no exclusion - it is only ever called by hooks, which are already
unsandboxed.