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

Claude Code mods (function-hook plugins) for working across many repos and many sessions at once — plus a pet.
| Mod | What it does |
|---|---|
| where-am-i | A band above the prompt: 📁 repo 🌿 branch ↑2 ± 3 changed 🎯 task. /task <text> names what this session is doing. |
| done-ping | A 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-guard | Blocks 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-pet | A 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-mood | Claude's mood in the status line: 🤔 reading, ✍️ writing, 🧪 testing, 😤 after a few errors, 😌 when done — with 📖 ✏️ ⚡ 💥 counters. |
| control-tower | A 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. |
| tldr | After every long answer, a dim 💡 TL;DR line (Haiku) in the transcript, so a session you switch back to reads at a glance. |
| sdk-sync | Release 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. |
| fortune | Programmer jokes after the working spinner (Sauteing… 🎲 删库跑路前,记得先 git push。); the day's first prompt shows 今日运势 (/fortune). Fridays never deploy. |
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.
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>hooks/register.tsx 239 lines1import 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}
239types/index.d.ts 19 lines1export 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