SLOPSHOPPER

control-tower

/tower: one pane showing every Claude Code session on this machine — which is working, which waits for you, which is done

newpanebandguardcommandprompt
v0.3.0no licenseupdated 2026-10-09YuehengHan/my-claude-mods/plugins/control-tower
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · control-tower
│ ┃ 🗼 Control tower ✕ › fix the failing auth test and add an audit log call │ ┃ No live sessions yet. Each one shows up │ ┃ after its first prompt. ⏺ 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 │ │ › /tower │ ⎿ control-tower: Control tower opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 🗼 Control tower
No live sessions yet. Each one shows up after its first prompt.
README

my-claude-mods

Claude Code mods (function-hook plugins) for working across many repos and many sessions at once — plus a pet.

ModWhat it does
where-am-iA band above the prompt: 📁 repo 🌿 branch ↑2 ± 3 changed 🎯 task. /task <text> names what this session is doing.
done-pingA macOS notification (repo, duration, your prompt) with a sound per kind: 🔔 Glass when a turn over 30 s is done, Basso when it failed, Submarine when Claude is waiting on you. Change them in SOUNDS.
repo-guardBlocks push --force, pushes to main/master, reset --hard, clean -f, checkout ., branch -D, stash clear, rm -rf ~. Asks before Claude first edits a repo other than the session's own.
claude-petA pet that earns xp from your work, grows through five stages and unlocks achievements. Pick a species (/pet species: chick, cat, dog, dragon, dino, ocean, bug, plant, robot, moon) or your own emojis (/pet emoji 🦊, /pet emoji 🥚 🐣 🦊 🐺 🐉). Lives in the status line (🦮 xixi (・_・)📖 Lv7 ▰▱▱▱▱▱) with 18 moods that follow Claude: 💭 thinking, 📖 reading, ✍️ writing, ⚡ running, 🧪 testing, 🌐 browsing, 📣 delegating, ✋ waiting for you, 🔥 on a 25-call streak, 🌙 past 1am, 🎉 tests pass, 📦 commit/push, (×_×) error, (╬ಠ益ಠ) three in a row, bored after 5 idle minutes, asleep after 15. /pet for stats, /pet name <name>.
claude-moodClaude's mood in the status line: 🤔 reading, ✍️ writing, 🧪 testing, 😤 after a few errors, 😌 when done — with 📖 ✏️ ⚡ 💥 counters.
control-towerA row under where-am-i's, shown only while another session waits for you (a permission or a question): ✋ backend-ng 在等你 2m [tower]. /tower (or the button) opens a pane listing every Claude session on this machine, named repo · branch for worktrees.
tldrAfter every long answer, a dim 💡 TL;DR line (Haiku) in the transcript, so a session you switch back to reads at a glance.
sdk-syncRelease audit for the SpatialReal SDKs. Never acts while you work: when a new v* tag appears it reminds you once; /release-check <repo> [tag] gathers the facts (public API files changed, CHANGELOG entry, commits in each sibling SDK / docs / examples since the release, version pins) and asks Claude for a read-only audit table. /release-check status lists unchecked releases; --facts skips the audit. Graph: plugins/sdk-sync/hooks/sdk-map.ts.
fortuneProgrammer jokes after the working spinner (Sauteing… 🎲 删库跑路前,记得先 git push。); the day's first prompt shows 今日运势 (/fortune). Fridays never deploy.

Install

At a Claude Code terminal prompt:

/plugin marketplace add YuehengHan/my-claude-mods
/plugin install where-am-i@my-claude-mods
/plugin install done-ping@my-claude-mods
/plugin install repo-guard@my-claude-mods
/plugin install claude-pet@my-claude-mods
/plugin install claude-mood@my-claude-mods

Pick the user scope so they load in every session. Install only the ones you want.

Developing

Add a local clone as the marketplace instead, and Claude Code reads the plugins straight from the folder:

claude plugin marketplace add ~/Desktop/my-claude-mods
claude plugin install where-am-i@my-claude-mods --scope user

Claude Code runs an installed copy, so after editing a mod bump its version in plugin.json, then:

claude plugin marketplace update my-claude-mods
claude plugin update <mod>@my-claude-mods

and run /reload-plugins (or restart) in each open session.

Check and test a mod:

claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>
Source 2 files
hooks/register.tsx 239 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { TowerEntry, TowerState } from '../types'
4
5const PANE = 'control-tower'
6const SESSIONS = { plugin: 'control-tower', key: 'sessions' } as const
7const SELF = { plugin: 'control-tower', key: 'self' } as const
8const NOW = { plugin: 'control-tower', key: 'now' } as const
9
10/** A session that has not written for this long has exited or crashed. */
11const GONE_AFTER_MS = 90_000
12const BEAT_MS = 15_000
13const SCAN_MS = 5_000
14
15export const ORDER: Record<TowerState, number> = { waiting: 0, failed: 1, working: 2, done: 3, idle: 4, ended: 5 }
16
17export const LOOK: Record<TowerState, { icon: string; label: string; color: string }> = {
18  waiting: { icon: '✋', label: '等你', color: 'warning' },
19  failed: { icon: '❌', label: '出错', color: 'error' },
20  working: { icon: '⏳', label: '在跑', color: 'suggestion' },
21  done: { icon: '✅', label: '完成', color: 'success' },
22  idle: { icon: '💤', label: '空闲', color: 'inactive' },
23  ended: { icon: '⏹', label: '已关', color: 'inactive' },
24}
25
26export function ago(ms: number): string {
27  const s = Math.max(0, Math.round(ms / 1000))
28  if (s < 60) return `${s}s`
29  const m = Math.floor(s / 60)
30  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 ? `${m % 60}m` : ''}`
31}
32
33export function oneLine(text: string, max = 80): string {
34  const t = text.replace(/\s+/g, ' ').trim()
35  return t.length > max ? `${t.slice(0, max - 1)}…` : t
36}
37
38/** Live sessions, the ones needing you first. */
39export function arrange(entries: readonly TowerEntry[], now: number): TowerEntry[] {
40  return entries
41    .filter(s => s.state !== 'ended' && now - s.updatedAt < GONE_AFTER_MS)
42    .sort((a, b) => ORDER[a.state] - ORDER[b.state] || b.since - a.since)
43}
44
45export type BannerItem = { repo: string; age: string }
46
47/** The banner: only the other sessions waiting for you (a permission or a question). */
48export function bannerOf(entries: readonly TowerEntry[], self: string, now: number): BannerItem[] {
49  return arrange(entries, now)
50    .filter(s => s.id !== self && s.state === 'waiting')
51    .map(s => ({ repo: s.repo, age: ago(now - s.since) }))
52}
53
54let me: TowerEntry | null = null
55let dir = ''
56
57const basename = (path: string) => path.replace(/\/+$/, '').split('/').pop() || path
58
59/** `backend-ng`, or `backend-ng · fix/error-codes` in a worktree or a renamed checkout. */
60export function nameOf(remote: string, folder: string, branch: string): string {
61  const repo = remote.trim().replace(/\.git$/, '').split(/[/:]/).pop() || folder
62  if (folder === repo || branch === '' || branch === 'HEAD') return repo
63  return `${repo} · ${branch}`
64}
65
66async function displayName($: EngineInterface): Promise<string> {
67  const run = async (args: string[]) => {
68    const r = await $.process.run(['git', ...args], { timeoutMs: 5000 }).catch(() => null)
69    return r !== null && r.exitCode === 0 ? r.stdout.trim() : ''
70  }
71  const top = await run(['rev-parse', '--show-toplevel'])
72  if (top === '') return basename(await $.session.cwd())
73  return nameOf(await run(['remote', 'get-url', 'origin']), basename(top), await run(['rev-parse', '--abbrev-ref', 'HEAD']))
74}
75
76async function init($: EngineInterface) {
77  const home = (await $.env.get('HOME')) ?? ''
78  dir = `${home}/.claude/control-tower`
79  const repo = await displayName($)
80  const now = await $.clock.now()
81  me = { id: await $.session.id(), repo, task: '', prompt: '', last: '', state: 'idle', since: now, updatedAt: now }
82  await $.state.set(SELF, me.id)
83  await save($, {})
84}
85
86async function save($: EngineInterface, patch: Partial<TowerEntry>) {
87  if (me === null || dir === '') return
88  const now = await $.clock.now()
89  const stateChanged = patch.state !== undefined && patch.state !== me.state
90  me = { ...me, ...patch, since: stateChanged ? now : me.since, updatedAt: now }
91  await $.fs.write(`${dir}/${me.id}.json`, JSON.stringify(me))
92  await scan($)
93}
94
95async function scan($: EngineInterface) {
96  if (dir === '') return
97  const now = await $.clock.now()
98  const found: TowerEntry[] = []
99  const files = await $.fs.list(dir).catch(() => [])
100  for (const f of files) {
101    // Files untouched for a day belong to long-gone sessions: skip without reading.
102    if (!f.name.endsWith('.json') || now - f.mtimeMs > 86_400_000) continue
103    try {
104      found.push(JSON.parse(String(await $.fs.read(`${dir}/${f.name}`))) as TowerEntry)
105    } catch {
106      // A file mid-write: next scan.
107    }
108  }
109  await $.state.set(SESSIONS, arrange(found, now))
110  await $.state.set(NOW, now)
111}
112
113function safely(work: Promise<unknown>) {
114  return work.catch(() => undefined)
115}
116
117export const register: Register = on => {
118  on('session.start', async ($, e, next) => {
119    await $.command.register({ name: 'tower', description: '🗼 Every Claude session on this machine: working, waiting for you, done' })
120    void safely(init($))
121    $.clock.every(BEAT_MS, () => void safely(save($, {})))
122    $.clock.every(SCAN_MS, () => void safely(scan($)))
123    return next(e)
124  })
125
126  on('command.run', { command: 'tower' }, async $ => {
127    await safely(scan($))
128    await $.ui.open({ id: PANE, title: '🗼 Control tower' })
129    return { text: 'Control tower opened.' }
130  })
131
132  // where-am-i's /task names the session here too.
133  on('command.run', { command: 'task' }, ($, e, next) => {
134    void safely(save($, { task: e.args.trim() }))
135    return next(e)
136  })
137
138  on('prompt.submit', ($, e, next) => {
139    void safely(save($, { state: 'working', prompt: oneLine(e.text) }))
140    return next(e)
141  })
142
143  on('classic.Notification', async ($, e, next) => {
144    const ran = await next(e)
145    if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') {
146      void safely(save($, { state: 'waiting' }))
147    }
148    return ran
149  })
150
151  on('tool.call', async ($, e, next) => {
152    const ran = await next(e)
153    if (me?.state === 'waiting') void safely(save($, { state: 'working' }))
154    return ran
155  })
156
157  on('turn.complete', async ($, e, next) => {
158    const done = await next(e)
159    if (e.agentId === undefined) {
160      const state: TowerState = e.isAborted ? 'idle' : e.reason === 'answer' ? 'done' : 'failed'
161      void safely(save($, { state, last: oneLine(e.answer.split('\n').find(l => l.trim()) ?? '') }))
162    }
163    return done
164  })
165
166  on('classic.SessionEnd', async ($, e, next) => {
167    await safely(save($, { state: 'ended' }))
168    return next(e)
169  })
170
171  // A row under where-am-i's, only while another session waits for you.
172  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
173    const rest = await next(e)
174    if (e.props.hasSurvey) return rest
175    const { value: sessions = [] } = await $.state.get(SESSIONS)
176    const { value: self = '' } = await $.state.get(SELF)
177    const { value: now = 0 } = await $.state.get(NOW)
178    const waiting = bannerOf(sessions, self, now)
179    if (waiting.length === 0) return rest
180    const { Box, Text, Button } = $.ui.resolve(e)
181    return (
182      <Box flexDirection="column">
183        {rest}
184        <Box flexDirection="row" flexWrap="wrap">
185          {waiting.map(it => (
186            <Text color="warning" bold>
187              ✋ {it.repo} 在等你 {it.age}
188              {'   '}
189            </Text>
190          ))}
191          <Button key="tower" label="tower" plain onPress={() => $.ui.open({ id: PANE, title: '🗼 Control tower' })} />
192        </Box>
193      </Box>
194    )
195  })
196
197  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
198    const { Box, Text } = $.ui.resolve(e)
199    const { value: sessions = [] } = await $.state.get(SESSIONS)
200    const { value: self = '' } = await $.state.get(SELF)
201    const { value: now = 0 } = await $.state.get(NOW)
202    const width = Math.max(30, e.props.bodyColumns)
203    const repoWidth = Math.min(24, Math.max(...sessions.map(s => s.repo.length), 8))
204
205    if (sessions.length === 0) {
206      return <Text dimColor>No live sessions yet. Each one shows up after its first prompt.</Text>
207    }
208
209    const counts = (['waiting', 'working', 'done', 'failed'] as const)
210      .map(st => [st, sessions.filter(s => s.state === st).length] as const)
211      .filter(([, n]) => n > 0)
212      .map(([st, n]) => `${LOOK[st].icon}${n}`)
213      .join('  ')
214
215    return (
216      <Box flexDirection="column">
217        <Text dimColor>{counts}</Text>
218        {sessions.map(s => {
219          const look = LOOK[s.state]
220          const what = s.task || s.prompt || '—'
221          return (
222            <Box flexDirection="column" marginTop={1}>
223              <Text wrap="truncate-end">
224                <Text color={look.color} bold>{look.icon} {s.repo.padEnd(repoWidth)}</Text>
225                <Text color={look.color}> {look.label} {ago(now - s.since)}</Text>
226                {s.id === self && <Text dimColor> (本会话)</Text>}
227              </Text>
228              <Text wrap="truncate-end">   🎯 {oneLine(what, width - 6)}</Text>
229              {s.last !== '' && s.state !== 'working' && (
230                <Text dimColor wrap="truncate-end">   ↳ {oneLine(s.last, width - 6)}</Text>
231              )}
232            </Box>
233          )
234        })}
235      </Box>
236    )
237  })
238}
239
types/index.d.ts 19 lines
1export type TowerState = 'working' | 'waiting' | 'done' | 'failed' | 'idle' | 'ended'
2
3export type TowerEntry = {
4  id: string
5  repo: string
6  task: string
7  prompt: string
8  last: string
9  state: TowerState
10  since: number
11  updatedAt: number
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'control-tower': { sessions: TowerEntry[]; self: string; now: number }
17  }
18}
19