How Pingme Lands You on the Exact Terminal Tab
Posting a notification is the easy part. Making the click land you on the exact terminal tab that pinged you is the hard part. How Pingme does it.
You kick off a Claude Code task and walk away. Twenty minutes later it has not moved. It stopped early, waiting for permission, and sat dead the whole time you were gone.
Pingme exists to close that gap. When Claude Code finishes, fails, or needs you, it posts a macOS notification. You click it and your terminal jumps to the front, on the exact tab running that session.
That last clause is where all the engineering lives. Posting the notification itself is the easy part, a handful of lines against a macOS API. The hard parts are two: staying alive long enough to matter when your host gives you five seconds, and making a click that lands an hour later go to the one tab, out of many, that pinged you.
Both problems push the work onto a second process that has to outlive the first.
Two processes, because macOS makes you
Pingme is a TypeScript CLI and a native Swift binary. The split is not a preference. macOS forces it.
Here is the whole pipeline.
Claude Code hook
│ JSON on stdin
▼
@inkuramu/pingme TypeScript CLI
reads the event, builds a request, spawns the binary
│ JSON on stdin
▼
InkuramuPingme.app Swift binary, inside a .app bundle
posts via UNUserNotificationCenter, owns the click
The CLI speaks Claude Code. It reads the hook payload, maps the event to a notification request, and finds the binary. The binary speaks macOS.
Why not do all of it in Node? Because macOS only delivers notifications reliably from a real .app bundle, and the click callback only fires while a native run loop is spinning. A run loop is the loop that keeps a process alive waiting on OS events, like your click, and you only get one from NSApplication.run(). A Node process is neither an app bundle nor a thing running an NSApplication. The native side is not an optimization. It is the only place the work can happen.
Problem one: outliving a five-second hook
Claude Code triggers Pingme through a hook: a shell command it runs at a lifecycle event, like a task finishing or a permission prompt, and waits on before continuing. Hooks run synchronously and get killed fast. Pingme registers its hooks with a five second timeout. While the hook runs, the task is blocked.
A notification has the opposite clock. A “task done” card can sit for a minute. A permission prompt might wait an hour, because it is waiting for you.
So the CLI and the card want opposite lifetimes. If the CLI waits for the click, the hook times out and the task stalls, which is the exact dead time you were trying to kill. If the card dies when the CLI returns, it is useless.
The fix is to detach. The CLI spawns the binary as a detached child in its own process group, so it is not killed when Claude tears down the hook, calls unref() so Node will not wait on it, writes one JSON request, and closes stdin.
const child = spawn(binaryPath, [], {
stdio: ["pipe", "pipe", "pipe"],
detached: detach,
});
if (detach) child.unref();
child.stdin.write(JSON.stringify(request));
child.stdin.end(); Then it reads the binary’s replies, which arrive as newline-delimited JSON. The binary emits delivered when the card is on screen, then dismissed, timeout, or error much later. The moment a delivered line arrives, the CLI breaks out of the loop and exits. The hook returns well inside its five seconds.
hook starts ─┐ five second budget
▼
CLI ──spawn──▶ binary posts the card
CLI ◀────────── "delivered"
CLI exits hook returns, task unblocks
binary keeps the card on screen
│ seconds ... an hour
▼
you click ──▶ binary focuses the tab
The detached binary keeps holding the card and waits for a click that may never come.
This is not fire and forget. The CLI does not launch the binary and hope. It waits for proof that the card was delivered, then walks away. Confirm, then detach.
Problem two: making the click land on the right tab
You have to be the one who posted it
On macOS, clicking a notification activates the app that posted it. That one rule shapes the entire click path.
Post the card from a Bash script with osascript, and clicking it brings osascript forward. Your terminal does not move. The click is wasted.
So Pingme has to be the poster. The binary that puts the card on screen is the same process that handles the click. The .app bundle is what lets macOS deliver the notification at all; owning the click is why that same bundle has to be a long-lived process that stays alive to catch it. It is also why “just shell out to osascript” does not work.
Which terminal am I even in?
Owning the click is step one. Knowing where to send it is harder.
When Claude Code runs, Pingme’s CLI is buried several processes below whatever terminal you launched. So it walks up the parent chain, up to twenty levels, matching each process name against a small table of known terminals until one hits.
const KNOWN_TERMINALS: Array<[RegExp, string]> = [
[/iTerm2$/, "iTerm2"],
[/^Terminal$/, "Terminal"],
[/ghostty$/, "ghostty"],
[/kitty$/, "kitty"],
[/alacritty$/, "alacritty"],
[/wezterm-gui$/, "WezTerm"],
[/Warp\.app/, "Warp"],
]; Here is the catch. The CLI cannot do the focusing. The click happens later, maybe an hour later, long after the CLI has exited. So the CLI just gathers clues: the session id, the tty (the terminal device file that names this one tab), the window id, the socket. It packs them into the notification payload and lets go. The binary reads them back when you actually click.
Every terminal focuses differently
There is no shared “focus this tab” API. Each terminal has its own mechanism, so each one is a separate integration.
iTerm2 takes AppleScript. The binary walks every window, tab, and session looking for the session id it stashed, then selects that tab.
tell application "iTerm2"
activate
repeat with w in every window
repeat with t in every tab of w
repeat with s in every session of t
if id of s is "the-session-id" then
select t
return
end if
end repeat
end repeat
end repeat
end tell Here the-session-id stands in for the value the CLI stashed in the payload.
The others each take their own path. Apple’s Terminal matches the tab by its tty. kitty listens on a socket, so the binary shells out to kitten @ --to <socket> focus-window --match id:.... WezTerm has its own CLI: wezterm cli activate-pane --pane-id .... For kitty and WezTerm the binary brings the app forward by its process id first, then runs that CLI to land on the exact pane.
Some terminals cannot target a tab at all. For those, bringing the app forward by its process id is the whole story. A window in front beats a window buried.
So every click runs through the same branch.
click
│
├─ iTerm2 → AppleScript, match session id → select tab
├─ Terminal → AppleScript, match tty → select tab
├─ kitty → PID, then kitten @ focus-window --match id
├─ WezTerm → PID, then wezterm cli activate-pane --pane-id
└─ other → bring the app forward by PID (no tab targeting)
Not every event deserves the same card
Owning the poster buys one more thing: control over how each event feels.
A finished task is calm: normal urgency, default sound. A failure should not feel like a success, so it gets critical urgency and the harsh Basso sound. A permission prompt is time sensitive, because you are the one blocking the work, so its timeout is zero, which arms no auto-dismiss timer. It waits for you. An idle nudge is the opposite: low urgency, no sound, easy to ignore.
On macOS those map cleanly onto interruption levels.
switch payload.urgency {
case "low": content.interruptionLevel = .passive
case "critical": content.interruptionLevel = .timeSensitive
default: content.interruptionLevel = .active
} There is a smaller touch on top. Every title carries a one character prefix: for example, a check for done, a siren for failure, a lock for permission. Leave it blank and the CLI hashes the project path into one of eight colored dots, so parallel sessions get a stable color you can tell apart at a glance.
A good notification triages itself. You should know what happened before you read a word.
Shipping a Swift binary through npm
There is one last packaging wrinkle: how do you put a Mac-only Swift binary inside an npm package without shipping Mac bytes to every Linux user?
You split the package too. The CLI is pure TypeScript. The binary lives in a second package, @inkuramu/pingme-darwin, declared as an optional dependency and marked "os": ["darwin"]. npm installs it only on macOS. Everyone else skips it cleanly.
The binary is universal, one file for Apple Silicon and Intel, and it ships inside a .app bundle. The bundle is not decoration. macOS will not reliably deliver notifications from a bare executable, and a flag in the Info.plist, LSUIElement, keeps the app out of the Dock so it stays invisible.
The lesson
Showing the notification is the easy part. The work is everywhere else: surviving a host that gives you five seconds, owning the click so it belongs to you, and carrying enough context in the payload to focus the right tab long after the process that gathered it is gone.
Everything that makes the notification useful happens after the click, once the CLI has already walked away.
Pingme is on npm, one command away. Go kill your own dead time.