SLOPSHOPPER

clux

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

newpanespinnertoastprocesstimer
v4.5.0MITupdated 2026-10-09ai-advanced-futures/clux/plugins/clux
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clux
│ ┃ sessions ✕ › fix the failing auth test and add an audit log call │ ┃ Background sessions · 0 [ Hide ] │ ┃ No background sessions for this repository. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ & sessions

Draws

Pane · sessions
Background sessions · 0 [ Hide ] No background sessions for this repository.
README

clux

tmux status bar notifications for Claude Code. See when a task finishes or needs you.

Quick start

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.

Requirements

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.

Mirror mode

/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.

Background sessions pane

/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
  • Each row shows the name, the status (needs input, working, unknown, done, failed, stopped), the PRs as links, and the description. The description is the question of a session that needs input, else what the session does now or the result it gave.
  • Press the number of a row (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.
  • clux draws nothing above the prompt. At the right end of the prompt footer, a dim sessions label opens the pane. When a session needs input, the label changes to the count, for example 1 needs input.
  • When a session writes needs input:, clux shows a toast and plays a sound, one time for each new question.
  • A session counts when its folder or its worktree is in the repository, also a worktree that 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).

Companion terminal

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.

More

MIT license.

Source 3 files
hooks/sessions/register.tsx 290 lines
1import { 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}
290
hooks/sessions/jobs.ts 142 lines
1// 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}
142
types/index.d.ts 21 lines
1export 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