Claude Code ↔ tmux notifications — status bar alerts when tasks finish or need input

tmux status bar notifications for Claude Code. See when a task finishes or needs you.
Start Claude Code inside tmux. Run:
/plugin marketplace add ai-advanced-futures/clux
/plugin install clux@clux
Restart Claude Code. Run:
/clux:setup
/clux:setup asks a few short questions and shows each change before it writes it. To check the result, run /clux:validate. It changes nothing.
After a plugin update, run /clux:upgrade. It keeps your setup answers and asks no questions. When a newer clux is available, it gives the commands to update the plugin.
tmux and bash ≥ 4.0. jq and flock are recommended. Python ≥ 3.10 and perl are only for the companion terminal (macOS and most Linux systems have perl). Without perl, terminal.sh gives exit code 2 and clux terminal needs perl.
/clux:follow on makes all terminals that are attached to tmux show the same session. When you change session in one terminal, the others go with it. /clux:follow off stops it.
/clux:sessions opens one pane for the background sessions of the current repository. /clux:sessions again closes it. ctrl+x b does the same from anywhere, also while you write a message. Your draft stays in the composer.
Background sessions · 3 [ Hide ]
1: tenant-registry-p1 ● needs input choose: YAML crosswalk or SQL table?
2: ce-db-roster ● working #41 Running the migration tests
3: mods-research ● done #28 #29 10 daily uses + gh-account mod
1 to 9), or Tab and then Enter, to open that session. In tmux, clux opens a new window that runs claude attach <id>. Outside tmux, it copies that command. Esc gives the keyboard back to the prompt.sessions label opens the pane. When a session needs input, the label changes to the count, for example 1 needs input.needs input:, clux shows a toast and plays a sound, one time for each new question.git worktree list names. The session that shows the pane is not in the list. A working session that has not written its state for 30 minutes shows as unknown.The pane is a function-hooks mod (hooks/sessions/). It needs Claude Code 2.1.291 or later. ctrl+x b is the chord of the app:cycleDiffBase action, which Claude Code uses only in the diff panel. To use a different chord, bind it to app:cycleDiffBase in ~/.claude/keybindings.json (context Global).
The clux:terminal skill gives Claude one tmux pane that you can see. Claude runs commands in it. A local model, Laya, examines each command before it runs. A dangerous command waits for your y. Install Laya one time. Claude asks you before it runs the install:
<plugin>/scripts/terminal.sh laya install
Background sessions. A Claude Code session with no tmux pane (claude --bg, or a session that a claude agents dashboard starts) also gets a companion. It opens as a new window, clux-terminal <id>, in the tmux session of the dashboard. With no dashboard, it opens on a private tmux server, and Claude gives you the line to attach to it. The companion closes when the session ends, also after a crash.
For more detail, read the reference.
MIT license.
hooks/sessions/register.tsx 290 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { BgSession, BgStatus } from '../../types'
5import {
6 LABEL,
7 isFresh,
8 newlyBlocked,
9 question,
10 sortSessions,
11 toSession,
12 worktreePaths,
13} from './jobs'
14
15const PANE = 'sessions'
16// commands/sessions.md declares it; the hook below answers it.
17const COMMAND = 'clux:sessions'
18// The chord that toggles the pane, also with a draft in the composer. No
19// engine action runs a plugin command, so the mod borrows this one: its
20// engine handler is mounted only in the diff panel, where it keeps its job.
21const TOGGLE_ACTION = 'app:cycleDiffBase'
22const TITLE = 'Background sessions'
23const POLL_MS = 5000
24// Read the worktree list again after this many polls (one minute).
25const ROOTS_EVERY = 12
26// How long a question stays known after its last poll. Longer than a roots
27// refresh, so a row that drops out for some polls does not alert again.
28const ALERT_MEMORY_MS = 5 * 60 * 1000
29const SOUND = 'sounds/needs-input.wav'
30// afplay on macOS; on Linux Claude Code has no player, so try clux's.
31const PLAYERS = ['afplay', 'paplay', 'pw-play', 'aplay', 'play']
32
33const sessions = atom({ plugin: 'clux', key: 'sessions' } as const, [])
34
35const COLOR: Record<BgStatus, string> = {
36 'needs-input': 'warning',
37 working: 'suggestion',
38 unknown: 'inactive',
39 done: 'success',
40 failed: 'error',
41 stopped: 'inactive',
42}
43
44// What a poll needs. Only `roots` changes, when a worktree is added.
45type Scope = { dir: string; roots: string[]; selfId: string }
46
47async function jobsDir($: EngineInterface): Promise<string> {
48 const config = await $.env.get('CLAUDE_CONFIG_DIR')
49 if (config) return `${config}/jobs`
50 return `${await $.env.get('HOME')}/.claude/jobs`
51}
52
53// The main working tree and every worktree of the repository, also a
54// worktree outside the main tree; the session's folder outside git. Each
55// root also as the path it lands on, for a root behind a symbolic link.
56async function repoRoots($: EngineInterface): Promise<string[]> {
57 const repo = await $.session.repo().catch(() => null)
58 let paths = [await $.session.cwd()]
59 if (repo) {
60 const listed = await $.process
61 .run(['git', '-C', repo.root, 'worktree', 'list', '--porcelain'])
62 .catch(() => undefined)
63 paths = [repo.root, ...(listed?.exitCode === 0 ? worktreePaths(listed.stdout) : [])]
64 }
65 const real = await Promise.all(
66 paths.map(path =>
67 $.fs.stat(path, { resolve: true }).then(stat => stat.realPath, () => undefined),
68 ),
69 )
70 return [...new Set([...paths, ...real.filter((path): path is string => !!path)])]
71}
72
73async function readSessions($: EngineInterface, scope: Scope, now: number) {
74 const entries = await $.fs.list(scope.dir).catch(() => [])
75 const texts = await Promise.all(
76 entries
77 .filter(entry => entry.kind === 'dir')
78 .map(async entry => ({
79 id: entry.name,
80 text: await $.fs.read(`${scope.dir}/${entry.name}/state.json`).catch(() => ''),
81 })),
82 )
83 const found = texts.flatMap(({ id, text }) => {
84 if (typeof text !== 'string' || text === '') return []
85 const session = toSession(id, text, scope.roots, scope.selfId, now)
86 return session && isFresh(session, now) ? [session] : []
87 })
88
89 return sortSessions(found)
90}
91
92let player: string | undefined
93
94async function playAlert($: EngineInterface) {
95 const file = `${$.plugin.root}/${SOUND}`
96 for (const name of player ? [player] : PLAYERS) {
97 const ran = await $.process.run([name, file], { timeoutMs: 5000 }).catch(() => undefined)
98 if (ran?.exitCode === 0) {
99 player = name
100 return
101 }
102 }
103}
104
105// The questions alerted recently, each with the last poll that saw it.
106const alerted = new Map<string, number>()
107
108// `isQuiet` takes a baseline: no sound for questions asked before the start.
109// One sound for each poll, however many questions it finds.
110async function poll($: EngineInterface, scope: Scope, isQuiet: boolean) {
111 const now = await $.clock.now()
112 const list = await readSessions($, scope, now)
113 const before = await read($, sessions)
114 if (JSON.stringify(list) !== JSON.stringify(before)) {
115 await update($, sessions, () => list)
116 }
117 for (const [key, seen] of alerted) {
118 if (now - seen > ALERT_MEMORY_MS) alerted.delete(key)
119 }
120 const fresh = newlyBlocked(list, before).filter(s => !alerted.has(question(s)))
121 for (const s of list) {
122 if (s.status === 'needs-input') alerted.set(question(s), now)
123 }
124 if (isQuiet || fresh.length === 0) return
125 for (const session of fresh) {
126 $.ui.toast(session.line ? `${session.name} needs input: ${session.line}` : `${session.name} needs input`)
127 }
128 void playAlert($)
129}
130
131// Asked (a command, a press) it seats at any width; `focus` hands it the keys.
132function openPane($: EngineInterface) {
133 return $.ui.open({ id: PANE, title: TITLE, focus: true })
134}
135
136// Selecting a session opens it: a new tmux window that attaches to it, or
137// the attach command on the clipboard outside tmux.
138async function attach($: EngineInterface, session: BgSession) {
139 const argv = ['claude', 'attach', session.id]
140 const pane = await $.env.get('TMUX_PANE')
141 if (pane && (await $.env.get('TMUX'))) {
142 // The new window goes in the tmux session of this pane, not in the
143 // session that tmux used last.
144 const own = await $.process
145 .run(['tmux', 'display-message', '-p', '-t', pane, '#{session_id}'])
146 .catch(() => undefined)
147 const target = own?.exitCode === 0 ? ['-t', `${own.stdout.trim()}:`] : []
148 const ran = await $.process
149 .run(['tmux', 'new-window', ...target, '-n', session.name, ...argv])
150 .catch(() => undefined)
151 if (ran?.exitCode === 0) return
152 }
153 const command = argv.join(' ')
154 const copied = await $.ui.copy({ text: command }).catch(() => undefined)
155 $.ui.toast(copied?.isCopied ? `Copied: ${command}` : `Run: ${command}`)
156}
157
158const countNeeds = (list: readonly BgSession[]) =>
159 list.filter(s => s.status === 'needs-input').length
160
161// The poll loop of this session: set at the start, read by each tick.
162const loop: { scope?: Scope; timer?: Timer; isPolling: boolean; polls: number } = {
163 isPolling: false,
164 polls: 0,
165}
166
167// One poll at a time, so a slow poll and the next tick never both alert.
168async function tick($: EngineInterface, isQuiet = false) {
169 const scope = loop.scope
170 if (!scope || loop.isPolling) return
171 loop.isPolling = true
172 try {
173 loop.polls += 1
174 if (loop.polls % ROOTS_EVERY === 0) scope.roots = await repoRoots($)
175 await poll($, scope, isQuiet)
176 } catch (error) {
177 $.ui.log(`clux sessions: poll failed: ${String(error)}`, { to: 'debug' })
178 } finally {
179 loop.isPolling = false
180 }
181}
182
183export const register: Register = on => {
184 on('session.start', async ($, e, next) => {
185 // A `-p` run or the SDK has no person to alert and no pane to show.
186 if (!e.isInteractive) return next(e)
187 try {
188 loop.scope = {
189 dir: await jobsDir($),
190 roots: await repoRoots($),
191 selfId: await $.session.id(),
192 }
193 // Only the first poll after the start is quiet.
194 await tick($, true)
195 loop.timer?.cancel()
196 loop.timer = $.clock.every(POLL_MS, () => void tick($))
197 } catch (error) {
198 $.ui.log(`clux sessions: start failed: ${String(error)}`, { to: 'debug' })
199 }
200
201 return next(e)
202 })
203
204 // `/clux:sessions` alone toggles the pane: it closes a pane that shows, and
205 // opens one that is closed or a tab behind another. `on` and `off` set it.
206 on('command.run', { command: COMMAND }, async ($, e) => {
207 const arg = e.args.trim()
208 const isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
209 if (arg === 'off' || (arg !== 'on' && isShown)) {
210 await $.ui.close({ id: PANE })
211 return { text: 'Background sessions pane closed.' }
212 }
213 await tick($)
214 await openPane($)
215
216 return { text: 'Background sessions pane opened. Press a number to open a session.' }
217 }).catch(($, e, next) => {
218 $.ui.log(`clux sessions: command failed: ${String(next.error)}`, { to: 'debug' })
219 return { text: 'The background sessions pane did not respond. Try the command again.' }
220 })
221
222 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
223 const { Box, Text, Button, Link } = $.ui.resolve(e)
224 const list = await read($, sessions)
225 const nameWidth = Math.min(28, Math.max(8, ...list.map(s => s.name.length)))
226
227 return (
228 <Box flexDirection="column">
229 <Box>
230 <Text bold>{TITLE} · {list.length} </Text>
231 {/* Over the footer's label: the chord closes an open pane. */}
232 <Button
233 key="close-sessions"
234 label="Hide"
235 action={TOGGLE_ACTION}
236 onPress={() => $.ui.close({ id: PANE })}
237 />
238 </Box>
239 {list.length === 0 && (
240 <Text dimColor>No background sessions for this repository.</Text>
241 )}
242 {list.map((s, i) => (
243 // Name, status and PRs keep their width; the description is cut.
244 <Box key={`row-${s.id}`}>
245 <Box flexShrink={0}>
246 <Button
247 key={`open-${s.id}`}
248 plain
249 hotkey={i < 9 ? String(i + 1) : undefined}
250 label={s.name.slice(0, nameWidth).padEnd(nameWidth)}
251 onPress={() => attach($, s)}
252 />
253 <Text color={COLOR[s.status]}> ● {LABEL[s.status].padEnd(12)}</Text>
254 {s.prs.map(pr => (
255 <Link key={`pr-${s.id}-${pr.id}`} href={pr.href} label={`#${pr.id} `} />
256 ))}
257 </Box>
258 <Box flexShrink={1} minWidth={0}>
259 <Text dimColor wrap="truncate-end">{s.line}</Text>
260 </Box>
261 </Box>
262 ))}
263 </Box>
264 )
265 })
266
267 // No band: one label at the end of the prompt footer, so the chord has a
268 // Button to press while the pane is closed. It is dim until a question waits.
269 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
270 const needs = countNeeds(await read($, sessions))
271 const { Box, Text, Button } = $.ui.resolve(e)
272
273 // The footer is one instance: draw what the hooks below draw, then this.
274 return (
275 <Box>
276 {await next(e)}
277 {e.props.modes.length > 0 && <Text dimColor> & </Text>}
278 <Button
279 key="open-sessions"
280 plain
281 dimColor={needs === 0}
282 label={needs > 0 ? `${needs} needs input` : 'sessions'}
283 action={TOGGLE_ACTION}
284 onPress={() => openPane($)}
285 />
286 </Box>
287 )
288 })
289}
290hooks/sessions/jobs.ts 142 lines1// Pure helpers: turn a background job's state.json into one row of the
2// sessions pane. No `$` here, so the tests can call these directly.
3
4import type { BgPr, BgSession, BgStatus } from '../../types'
5
6// Finished sessions older than this drop off the list.
7const KEEP_FINISHED_MS = 3 * 24 * 60 * 60 * 1000
8// A working session writes its state every few seconds. With no write for
9// this long it has probably stopped without a last write.
10const STALE_WORKING_MS = 30 * 60 * 1000
11
12type JobState = {
13 state?: string
14 tempo?: string
15 name?: string
16 detail?: string
17 needs?: string
18 output?: { result?: string } | null
19 children?: { id?: string; href?: string; kind?: string }[]
20 cwd?: string
21 worktreePath?: string
22 sessionId?: string
23 updatedAt?: string
24}
25
26const ORDER: Record<BgStatus, number> = {
27 'needs-input': 0,
28 working: 1,
29 unknown: 2,
30 done: 3,
31 failed: 4,
32 stopped: 5,
33}
34
35export const LABEL: Record<BgStatus, string> = {
36 'needs-input': 'needs input',
37 working: 'working',
38 unknown: 'unknown',
39 done: 'done',
40 failed: 'failed',
41 stopped: 'stopped',
42}
43
44// A state this mod does not know shows as unknown, so the row stays visible.
45function statusOf(job: JobState, updatedAt: number, now: number): BgStatus {
46 if (job.state === 'blocked' || job.tempo === 'blocked') return 'needs-input'
47 if (job.state === 'working' || job.state === 'running') {
48 return now - updatedAt > STALE_WORKING_MS ? 'unknown' : 'working'
49 }
50 if (job.state === 'done') return 'done'
51 if (job.state === 'failed') return 'failed'
52 if (job.state === 'stopped') return 'stopped'
53 return 'unknown'
54}
55
56function prsOf(job: JobState): BgPr[] {
57 return (job.children ?? []).flatMap(child =>
58 child.kind === 'pr' && child.id && child.href
59 ? [{ id: child.id, href: child.href }]
60 : [],
61 )
62}
63
64// True when `path` is `root` or a folder below it.
65export function isUnder(path: string | undefined, root: string): boolean {
66 if (!path) return false
67 const base = root.replace(/\/+$/, '')
68 return path === base || path.startsWith(base + '/')
69}
70
71// One state.json as a row, or undefined when it is not under one of the
72// repository's worktrees, is this session itself, or does not parse.
73export function toSession(
74 id: string,
75 text: string,
76 roots: readonly string[],
77 selfId: string,
78 now: number,
79): BgSession | undefined {
80 let job: JobState
81 try {
82 job = JSON.parse(text) as JobState
83 } catch {
84 return undefined
85 }
86 if (job.sessionId && job.sessionId === selfId) return undefined
87 const isOurs = roots.some(
88 root => isUnder(job.cwd, root) || isUnder(job.worktreePath, root),
89 )
90 if (!isOurs) return undefined
91 const updatedAt = Date.parse(job.updatedAt ?? '') || 0
92 const status = statusOf(job, updatedAt, now)
93 // The description: the question of a session that needs input, what the
94 // session does now, or the result it gave.
95 const line =
96 status === 'stopped' ? ''
97 : status === 'needs-input' ? (job.needs || job.detail || '')
98 : (job.detail || job.output?.result || '')
99
100 return {
101 id,
102 name: job.name || id,
103 status,
104 prs: prsOf(job),
105 line: line.replace(/\s+/g, ' ').trim(),
106 updatedAt,
107 }
108}
109
110export function isFresh(session: BgSession, now: number): boolean {
111 const isLive = session.status === 'needs-input' || session.status === 'working'
112 return isLive || now - session.updatedAt < KEEP_FINISHED_MS
113}
114
115export function sortSessions(list: readonly BgSession[]): BgSession[] {
116 return [...list].sort(
117 (a, b) => ORDER[a.status] - ORDER[b.status] || b.updatedAt - a.updatedAt,
118 )
119}
120
121// The sessions with a question that was not there at the last check: a new
122// session that needs input, or a new question from the same session.
123export const question = (s: BgSession) => `${s.id}\n${s.line}`
124
125export function newlyBlocked(
126 list: readonly BgSession[],
127 before: readonly BgSession[],
128): BgSession[] {
129 const asked = new Set(
130 before.filter(s => s.status === 'needs-input').map(question),
131 )
132 return list.filter(s => s.status === 'needs-input' && !asked.has(question(s)))
133}
134
135// The worktree paths that `git worktree list --porcelain` prints.
136export function worktreePaths(porcelain: string): string[] {
137 return porcelain
138 .split('\n')
139 .filter(line => line.startsWith('worktree '))
140 .map(line => line.slice('worktree '.length))
141}
142types/index.d.ts 21 lines1export type BgStatus = 'needs-input' | 'working' | 'unknown' | 'done' | 'failed' | 'stopped'
2
3export type BgPr = { id: string; href: string }
4
5export type BgSession = {
6 id: string
7 name: string
8 status: BgStatus
9 prs: BgPr[]
10 line: string
11 updatedAt: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'clux': {
17 sessions: BgSession[]
18 }
19 }
20}
21