Keeps a ledger of what Claude left running or lying around on your machine and servers

Three mods for Claude Code, built as function hook plugins. Requires Claude Code 2.1.287 or later.

| Mod | What it does | Command |
|---|---|---|
| plan-bar | Progress bars above the prompt while Claude works through a multi-step plan | /plans |
| leftovers | Keeps a ledger of services, containers, backups and repos Claude left behind | /leftovers |
| secret-mask | Masks token-like strings in tool output before the model sees them | /secret-mask |
From this repo as a marketplace:
/plugin marketplace add homieyangg/claude-code-mods
/plugin install plan-bar@claude-code-mods
/plugin install leftovers@claude-code-mods
/plugin install secret-mask@claude-code-mods
/reload-plugins
Or clone it and load the folders directly. Edits reload while a session is running.
git clone https://github.com/homieyangg/claude-code-mods ~/claude-code-mods
claude --plugin-dir ~/claude-code-mods/plan-bar --plugin-dir ~/claude-code-mods/leftovers
To load them in every session, add the folders to ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/claude-code-mods/plan-bar:~/claude-code-mods/leftovers:~/claude-code-mods/secret-mask"
}
}

Each plan gets one row above the prompt: the current stage, a bar split by stage, and a percentage. The row turns yellow while Claude is waiting on you and red when a task fails. A short sound plays when a stage finishes, when the plan waits, and when it ends.
Claude drives the bars itself. The mod registers two tools, plan_set and plan_update, and adds a short note to the system prompt asking Claude to use them for work with three or more steps.
| Command | |
|---|---|
/plans | List the current plans |
/plans demo | Run a 20 second demo |
/plans clear | Remove all plans |
/plans sound off / on | Turn sounds off or on |

leftovers watches the Bash commands Claude runs, locally and over ssh, and writes down anything that keeps running or stays on disk after the session:
| Kind | Recorded from |
|---|---|
| launchd | launchctl bootstrap, launchctl load |
| systemd | systemctl enable, systemctl start |
| cron | crontab edits |
| docker | docker run -d, docker compose up -d |
| background | nohup, tmux new -d |
| repo | gh repo create, git worktree add |
| backup | copies or moves to .bak, .orig, .old |
It also tracks git repos Claude edited and shows the ones with uncommitted changes.
Above the prompt, a yellow dot shows how many are left next to the first item; click the count to expand the rest. /leftovers shows the full list grouped by machine. Clean up asks Claude to remove the item using the undo command it recorded, Done drops it from the ledger. Uncommitted repos get See changes and Ignore. When Claude runs the undo command itself (docker rm -f, systemctl disable, git worktree remove and so on) the item drops off on its own. The ledger is kept across sessions.

| Command | |
|---|---|
/leftovers | Show the list |
/leftovers drop <n> | Remove item n from the ledger |
/leftovers clear | Empty the ledger |

When a Bash command prints something that looks like a credential, secret-mask replaces it with its first four characters and …(masked) before the output reaches the model. Results from other tools, such as MCP tools and web fetches, are masked when they are written to the conversation. A toast says how many were masked, and /secret-mask lists them.
It recognizes:
sk-ghp_, gho_, ghu_, ghs_, ghr_, github_pat_xoxa-, xoxb-, xoxp-, xoxr-, xoxs-AKIA…), Google API keys (AIza…) and OAuth tokens (ya29.…)Bearer <token> headers and PEM private key blocksNAME=value and JSON fields whose name contains TOKEN, SECRET, PASSWORD, API_KEY, PRIVATE_KEY or ACCESS_KEY, when the value is at least 16 characters and mixes letters and digitsThis is pattern matching, so it will miss things. Results from Read, Edit, Write and NotebookEdit are left alone, because Claude needs the real file contents to edit them. Treat it as a guard against accidents, not as a way to hand Claude a file full of secrets.
| Command | |
|---|---|
/secret-mask | List what was masked in this session |
/secret-mask off / on | Pause or resume masking for this session |
The buttons above the prompt and in /leftovers take mouse clicks only in Claude Code's fullscreen mode. The default renderer does not turn on mouse reporting, so clicks never reach it. Turn fullscreen on in /config, or add "tui": "fullscreen" to ~/.claude/settings.json. In the default mode, press ctrl+x then tab to move into the row above the prompt, tab to pick a button and Enter to press it.
Each mod has a language option: en (default), zh-TW or zh-CN. Change it in /config, or in ~/.claude/settings.json:
{
"pluginConfigs": {
"leftovers@claude-code-mods": { "options": { "language": "zh-TW" } }
}
}
Use leftovers@inline as the key when the mod is loaded with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS.
/plugin install notes that the option is not set yet. You can ignore that; it falls back to en.
claude plugin validate ./leftovers
claude plugin test ./leftovers
The demo recordings are made with VHS. media/tapes/setup-demo.sh builds a separate Claude Code config in ~/.claude-demo and a sample project in ~/acme-app; log in once with CLAUDE_CONFIG_DIR=~/.claude-demo claude auth login, then run media/tapes/record.sh plan-bar leftovers secret-mask hero.
MIT
hooks/register.tsx 417 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { DirtyRepo, Leftover } from '../types'
5import {
6 LOCAL,
7 applyDetected,
8 dayOf,
9 detect,
10 hostName,
11 labelName,
12 listText,
13 migrated,
14 ordered,
15 whatOf,
16} from './detect'
17import type { Place } from './detect'
18import { STRINGS, langOf } from './i18n'
19import type { Strings } from './i18n'
20
21const MAX_REPOS = 20
22// Gruvbox:收掉是會動到東西的動作用黃,完成用綠。
23// Button 平常不能上色,滑鼠移到那一列才會亮
24const UNDO_COLOR = '#fabd2f'
25const DONE_COLOR = '#b8bb26'
26const SCRATCH = /^\/(private\/)?(tmp|var\/folders)\//
27
28const items = atom({ plugin: 'leftovers', key: 'items' } as const, [])
29const dirty = atom({ plugin: 'leftovers', key: 'dirty' } as const, [])
30const isOpen = atom({ plugin: 'leftovers', key: 'isOpen' } as const, false)
31
32type Entry = { n: number; item: Leftover } | { n: number; repo: DirtyRepo }
33
34const stored = async <T,>($: EngineInterface, key: string): Promise<T[]> => {
35 const value = await $.store.get(key)
36
37 return Array.isArray(value) ? (value as T[]) : []
38}
39
40const ledger = async ($: EngineInterface) => (await stored<Leftover>($, 'items')).map(migrated)
41
42// 帳本放 store 才能跨 session;每次重讀再寫,另一個 session 同時在記也不會整份互蓋
43const mutate = async ($: EngineInterface, fn: (list: Leftover[]) => Leftover[]) => {
44 const list = fn(await ledger($))
45 await $.store.set('items', list)
46 await update($, items, () => list)
47}
48
49// worktree 常被 script 整批清掉,指令裡看不到 remove,所以直接看目錄還在不在
50const pruneGone = async ($: EngineInterface) => {
51 const home = (await $.env.get('HOME')) ?? ''
52 const gone: string[] = []
53
54 for (const item of await ledger($)) {
55 const path = item.label.startsWith('~') && home !== '' ? `${home}${item.label.slice(1)}` : item.label
56
57 if (item.kind === 'repo' && item.host === LOCAL && path.startsWith('/') && !(await $.fs.exists(path))) {
58 gone.push(item.id)
59 }
60 }
61
62 if (gone.length > 0) {
63 await mutate($, list => list.filter(item => !gone.includes(item.id)))
64 }
65}
66
67// 指令跑完工作目錄可能已經換了,相對路徑要用跑之前的目錄去接
68const placeOf = async ($: EngineInterface): Promise<Place> => {
69 try {
70 return { cwd: await $.session.cwd(), home: (await $.env.get('HOME')) ?? '' }
71 } catch {
72 return { cwd: '', home: '' }
73 }
74}
75
76const git = async ($: EngineInterface, dir: string, ...args: string[]) => {
77 try {
78 const ran = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: 5000 })
79
80 return ran.exitCode === 0 ? ran.stdout : undefined
81 } catch {
82 return undefined
83 }
84}
85
86const askToUndo = ($: EngineInterface, t: Strings, item: Leftover) =>
87 $.prompt.submit({
88 text: t.askToUndo(whatOf(t, item), hostName(t, item.host), item.day, item.undo),
89 asUser: true,
90 })
91
92const askToReview = ($: EngineInterface, t: Strings, repo: DirtyRepo) =>
93 $.prompt.submit({ text: t.askToReview(repo.label), asUser: true })
94
95// 目錄對到哪個 repo 根目錄不會變,查過就記著
96const roots = new Map<string, string | undefined>()
97
98// 把動過的目錄對回 git repo 根目錄,記進要追蹤未 commit 狀態的清單
99const track = async ($: EngineInterface, paths: readonly string[]) => {
100 const home = (await $.env.get('HOME')) ?? ''
101 const ignored = await stored<string>($, 'ignored')
102 let repos = await stored<string>($, 'repos')
103 const before = repos.length
104
105 for (const raw of paths) {
106 const dir = raw.startsWith('~') ? `${home}${raw.slice(1)}` : raw
107
108 if (!dir.startsWith('/') || SCRATCH.test(dir)) {
109 continue
110 }
111 if (!roots.has(dir)) {
112 roots.set(dir, (await git($, dir, 'rev-parse', '--show-toplevel'))?.trim())
113 }
114
115 const root = roots.get(dir)
116
117 if (root !== undefined && !ignored.includes(root) && !repos.includes(root)) {
118 repos = [...repos, root].slice(-MAX_REPOS)
119 }
120 }
121
122 if (repos.length !== before) {
123 await $.store.set('repos', repos)
124 }
125}
126
127const refreshDirty = async ($: EngineInterface) => {
128 const home = (await $.env.get('HOME')) ?? ''
129 const repos = await stored<string>($, 'repos')
130 const alive: string[] = []
131 const found: DirtyRepo[] = []
132
133 for (const path of repos) {
134 const status = await git($, path, 'status', '--porcelain')
135
136 if (status === undefined) {
137 continue
138 }
139 alive.push(path)
140
141 const files = status.split('\n').filter(line => line.trim() !== '').length
142
143 if (files > 0) {
144 const label = home !== '' && path.startsWith(home) ? `~${path.slice(home.length)}` : path
145 found.push({ path, label, files })
146 }
147 }
148
149 if (alive.length !== repos.length) {
150 await $.store.set('repos', alive)
151 }
152 if (JSON.stringify(found) !== JSON.stringify(await read($, dirty))) {
153 await update($, dirty, () => found)
154 }
155}
156
157const ignoreRepo = async ($: EngineInterface, path: string) => {
158 await $.store.set('ignored', [...(await stored<string>($, 'ignored')), path])
159 await $.store.set(
160 'repos',
161 (await stored<string>($, 'repos')).filter(one => one !== path),
162 )
163 await update($, dirty, list => list.filter(repo => repo.path !== path))
164}
165
166// band 的一列:一項加它的兩顆按鈕,key 跟輸出列的按鈕同一套
167const bandRow = (
168 $: EngineInterface,
169 t: Strings,
170 { Box, Button, Text }: Pick<ElementTable, 'Box' | 'Button' | 'Text'>,
171 entry: Entry,
172) =>
173 'item' in entry ? (
174 <Box key={`item-${entry.n}`} columnGap={1}>
175 <Text wrap="truncate-middle">{whatOf(t, entry.item)}</Text>
176 <Text dimColor>{hostName(t, entry.item.host)}</Text>
177 <Button
178 key={`undo-${entry.n}`}
179 label={t.undo}
180 hover={{ color: UNDO_COLOR }}
181 onPress={() => {
182 void askToUndo($, t, entry.item)
183 }}
184 />
185 <Button
186 key={`drop-${entry.n}`}
187 label={t.done}
188 dimColor
189 hover={{ color: DONE_COLOR, dimColor: false }}
190 onPress={() => mutate($, all => all.filter(one => one.id !== entry.item.id))}
191 />
192 </Box>
193 ) : (
194 <Box columnGap={1}>
195 <Text wrap="truncate-start">{entry.repo.label}</Text>
196 <Text dimColor>{t.uncommitted(entry.repo.files)}</Text>
197 <Button
198 key={`review-${entry.n}`}
199 label={t.review}
200 onPress={() => {
201 void askToReview($, t, entry.repo)
202 }}
203 />
204 <Button key={`skip-${entry.n}`} label={t.ignore} dimColor onPress={() => ignoreRepo($, entry.repo.path)} />
205 </Box>
206 )
207
208// 記帳出錯不能連累工具本身的結果,只留一行 debug log
209const quietly = async ($: EngineInterface, work: () => Promise<void>) => {
210 try {
211 await work()
212 } catch {
213 $.ui.log('leftovers: bookkeeping failed', { to: 'debug' })
214 }
215}
216
217export const register: Register = (on, options) => {
218 const t = STRINGS[langOf(options)]
219
220 on('session.start', async ($, e, next) => {
221 await $.command.register({ name: 'leftovers', description: t.description, argumentHint: t.argumentHint })
222 await quietly($, async () => {
223 await mutate($, list => list)
224 await pruneGone($)
225 await refreshDirty($)
226 })
227
228 return next(e)
229 })
230
231 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
232 const place = await placeOf($)
233 const ran = await next(e)
234
235 if (ran.deny !== undefined || ran.isError === true) {
236 return ran
237 }
238
239 await quietly($, async () => {
240 const detected = detect(e.command, place)
241
242 if (detected.ops.length > 0) {
243 const meta = { day: dayOf(await $.clock.now()), session: (await $.session.id()).slice(0, 8) }
244 await mutate($, list => applyDetected(list, detected, meta))
245 }
246 if (detected.dirs.length > 0) {
247 await track($, detected.dirs)
248 }
249 })
250
251 return ran
252 })
253
254 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
255 const ran = await next(e)
256
257 await quietly($, () => track($, [e.file_path.slice(0, e.file_path.lastIndexOf('/'))]))
258
259 return ran
260 })
261
262 on('turn.complete', async ($, e, next) => {
263 const ended = await next(e)
264
265 if (e.agentId === undefined) {
266 await quietly($, async () => {
267 await pruneGone($)
268 await refreshDirty($)
269 })
270 }
271
272 return ended
273 })
274
275 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
276 const entries: Entry[] = [
277 ...ordered(await read($, items)).map((item, index) => ({ n: index + 1, item })),
278 ...(await read($, dirty)).map((repo, index) => ({ n: index + 1, repo })),
279 ]
280 const first = entries[0]
281
282 if (e.props.hasSurvey || first === undefined) {
283 return next(e)
284 }
285
286 const ui = $.ui.resolve(e)
287 const { Box, Button, Text } = ui
288 const isListed = entries.length > 1 && (await read($, isOpen))
289
290 return (
291 <Box flexDirection="column">
292 {await next(e)}
293 <Box columnGap={1}>
294 <Text color="yellow">●</Text>
295 {entries.length > 1 ? (
296 <Button
297 key="toggle"
298 label={`${t.left(entries.length)} ${isListed ? '▴' : '▾'}`}
299 plain
300 dimColor
301 onPress={() => update($, isOpen, open => !open)}
302 />
303 ) : (
304 <Text dimColor>{t.left(entries.length)}</Text>
305 )}
306 {!isListed && bandRow($, t, ui, first)}
307 </Box>
308 {isListed && (
309 <Box flexDirection="column" paddingLeft={2}>
310 {entries.map(entry => bandRow($, t, ui, entry))}
311 </Box>
312 )}
313 </Box>
314 )
315 })
316
317 // pane 在 fullscreen 會被 dock 到右邊,位置 mod 管不了,所以清單畫在指令的輸出列
318 on('ui.render', { component: 'CommandOutput', props: { command: 'leftovers' } }, async ($, e, next) => {
319 if (e.props.isErrored || e.props.args.trim().startsWith('clear')) {
320 return next(e)
321 }
322
323 const { Box, Button, Text } = $.ui.resolve(e)
324 const list = ordered(await read($, items))
325 const repos = await read($, dirty)
326
327 if (list.length === 0 && repos.length === 0) {
328 return (
329 <Box>
330 <Text dimColor>{t.nothingLeft}</Text>
331 </Box>
332 )
333 }
334
335 const hosts = [...new Set(list.map(item => item.host))]
336
337 return (
338 <Box flexDirection="column">
339 {hosts.map(host => (
340 <Box flexDirection="column" marginBottom={1}>
341 <Text bold>{hostName(t, host)}</Text>
342 {list.map((item, index) =>
343 item.host !== host ? null : (
344 <Box key={`line-${index + 1}`} columnGap={1}>
345 <Text dimColor>{item.day}</Text>
346 <Text>{t.kind[item.kind]}</Text>
347 <Text wrap="truncate-middle">{labelName(t, item.label)}</Text>
348 <Button
349 key={`undo-${index + 1}`}
350 label={t.undo}
351 hover={{ color: UNDO_COLOR }}
352 onPress={() => {
353 void askToUndo($, t, item)
354 }}
355 />
356 <Button
357 key={`drop-${index + 1}`}
358 label={t.done}
359 dimColor
360 hover={{ color: DONE_COLOR, dimColor: false }}
361 onPress={() => mutate($, all => all.filter(one => one.id !== item.id))}
362 />
363 </Box>
364 ),
365 )}
366 </Box>
367 ))}
368 {repos.length > 0 && (
369 <Box flexDirection="column">
370 <Text bold>{t.uncommittedHeading}</Text>
371 {repos.map((repo, index) => (
372 <Box columnGap={1}>
373 <Text wrap="truncate-start">{repo.label}</Text>
374 <Text dimColor>{t.files(repo.files)}</Text>
375 <Button
376 key={`review-${index + 1}`}
377 label={t.review}
378 onPress={() => {
379 void askToReview($, t, repo)
380 }}
381 />
382 <Button
383 key={`skip-${index + 1}`}
384 label={t.ignore}
385 dimColor
386 onPress={() => ignoreRepo($, repo.path)}
387 />
388 </Box>
389 ))}
390 </Box>
391 )}
392 </Box>
393 )
394 })
395
396 on('command.run', { command: 'leftovers' }, async ($, e) => {
397 const [verb, arg] = e.args.trim().split(/\s+/)
398
399 if (verb === 'clear') {
400 await mutate($, () => [])
401
402 return { text: t.cleared }
403 }
404 if (verb === 'drop') {
405 const target = ordered(await read($, items))[Number(arg) - 1]
406
407 if (target !== undefined) {
408 await mutate($, list => list.filter(item => item.id !== target.id))
409 }
410 }
411
412 await quietly($, () => refreshDirty($))
413
414 return { text: listText(t, ordered(await read($, items)), await read($, dirty)) }
415 })
416}
417hooks/detect.ts 431 lines1import type { DirtyRepo, Leftover, LeftoverKind } from '../types'
2import type { Strings } from './i18n'
3
4export const LOCAL = 'local'
5const GITHUB = 'GitHub'
6const UNNAMED = '(unnamed)'
7
8export type Found = Pick<Leftover, 'kind' | 'host' | 'label' | 'undo'>
9export type Op = { add: Found } | { drop: string }
10export type Detected = { ops: Op[]; dirs: string[] }
11export type Place = { cwd: string; home: string }
12
13type Context = { args: string[]; host: string; cwd: string; place: Place; out: Detected }
14
15const SSH_TAKES_VALUE = new Set('-o -p -i -l -F -J -L -R -D -E -S -W -b -c -e -m -w -Q -B -I'.split(' '))
16const COMPOSE_VERBS = new Set('up down run exec logs ps pull build restart stop start'.split(' '))
17const BACKUP = /\.(bak|backup|orig|old)[-.\w]*$/
18
19export const idOf = (kind: LeftoverKind, host: string, label: string) => `${kind}:${host}:${label}`
20
21// 帳本存的是語言中立的代號,顯示時才換成當下語言
22export const hostName = (t: Strings, host: string) => (host === LOCAL ? t.local : host)
23
24export const labelName = (t: Strings, label: string) => (label === UNNAMED ? t.unnamed : label)
25
26export const whatOf = (t: Strings, item: Leftover) => `${t.kind[item.kind]} ${labelName(t, item.label)}`
27
28// 舊版帳本把本機和未命名 container 記成中文,讀進來時換成現在的代號
29export const migrated = (item: Leftover): Leftover => {
30 const host = item.host === '本機' ? LOCAL : item.host
31 const label = item.label === '未命名 container' ? UNNAMED : item.label
32
33 return host === item.host && label === item.label ? item : { ...item, host, label, id: idOf(item.kind, host, label) }
34}
35
36const base = (path: string) => path.replace(/\/+$/, '').split('/').pop() ?? path
37
38const plain = (words: readonly string[]) => words.filter(word => !word.startsWith('-'))
39
40const add = (ctx: Context, kind: LeftoverKind, label: string, undo: string, host = ctx.host) => {
41 const where = host === LOCAL || host === GITHUB ? undo : `ssh ${host} '${undo}'`
42 ctx.out.ops.push({ add: { kind, host, label, undo: where } })
43}
44
45const drop = (ctx: Context, kind: LeftoverKind, label: string, host = ctx.host) => {
46 ctx.out.ops.push({ drop: idOf(kind, host, label) })
47}
48
49// 引號攤平再切段,ssh 帶的遠端指令就能跟本機指令用同一套規則看
50const SHELLS = new Set(['ssh', 'bash', 'sh', 'zsh'])
51
52// 餵給 cat、python 這類的 heredoc 是資料,裡面長得像指令的行不能記;餵給 shell 或 ssh 的才是指令
53const withoutDataHeredocs = (command: string): string =>
54 command.replace(
55 /<<-?[ \t]*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g,
56 (whole: string, _quote: string, _tag: string, rest: string, offset: number) => {
57 const line = command.slice(command.lastIndexOf('\n', offset) + 1, offset)
58 const head = line
59 .split(/;|&&|\|\||\|/)
60 .pop()
61 ?.trim()
62 .split(/\s+/)
63 .find(word => word !== 'sudo')
64
65 return SHELLS.has(head ?? '') ? whole : rest
66 },
67 )
68
69const segmentsOf = (command: string): string[][] =>
70 withoutDataHeredocs(command)
71 .replace(/\$\([^)]*\)/g, 'X')
72 .replace(/=(['"])([^'"\n]*)\1/g, '=$2')
73 .replace(/['"]/g, ' ')
74 .split(/;|&&|\|\||\||\n/)
75 .map(part =>
76 part
77 .trim()
78 .split(/\s+/)
79 .filter(word => word !== '' && word !== 'sudo'),
80 )
81 .filter(words => words.length > 0)
82
83const ASSIGNMENT = /^([A-Za-z_]\w*)=(.*)$/
84const VARIABLE = /\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g
85
86// 同一條指令前面設過的變數先代回去,P=x.plist; launchctl bootstrap gui/1 $P 才會記成 x 而不是 $P
87const expanded = (words: readonly string[], vars: ReadonlyMap<string, string>): string[] =>
88 words.map(word =>
89 word.replace(VARIABLE, (whole: string, braced?: string, bare?: string) => vars.get(braced ?? bare ?? '') ?? whole),
90 )
91
92// 整段都是 NAME=value(可帶 export)才算設變數,回傳是否吃掉這段
93const assignedInto = (vars: Map<string, string>, words: readonly string[]): boolean => {
94 const pairs = words[0] === 'export' ? words.slice(1) : words
95
96 if (pairs.length === 0 || !pairs.every(word => ASSIGNMENT.test(word))) {
97 return false
98 }
99 pairs.forEach(word => {
100 const [, name = '', value = ''] = ASSIGNMENT.exec(word) ?? []
101
102 vars.set(name, value)
103 })
104
105 return true
106}
107
108const stripSsh = (words: readonly string[]): { host: string; rest: string[] } | undefined => {
109 if (words[0] !== 'ssh') {
110 return undefined
111 }
112
113 let at = 1
114
115 while (words[at]?.startsWith('-')) {
116 at += SSH_TAKES_VALUE.has(words[at] ?? '') ? 2 : 1
117 }
118
119 const host = words[at]
120
121 return host === undefined ? undefined : { host, rest: words.slice(at + 1) }
122}
123
124const valueAfter = (args: readonly string[], flag: string): string | undefined => {
125 const at = args.indexOf(flag)
126
127 return at >= 0 ? args[at + 1] : args.find(word => word.startsWith(`${flag}=`))?.slice(flag.length + 1)
128}
129
130// 把路徑接上當時的工作目錄變成絕對路徑,家目錄縮成 ~。
131// 帳本跨 session 共用,相對路徑事後查不到是哪個 repo 的
132const anchored = (place: Place, parts: readonly string[]): string => {
133 const joined = [place.cwd, ...parts].reduce((at, part) => {
134 if (part === '') {
135 return at
136 }
137
138 return at === '' || part.startsWith('/') || part.startsWith('~') ? part : `${at}/${part}`
139 }, '')
140 const full = joined.startsWith('~') && place.home !== '' ? `${place.home}${joined.slice(1)}` : joined
141
142 if (!full.startsWith('/')) {
143 return parts.at(-1) ?? ''
144 }
145
146 const kept: string[] = []
147
148 for (const part of full.split('/')) {
149 if (part === '..') {
150 kept.pop()
151 } else if (part !== '' && part !== '.') {
152 kept.push(part)
153 }
154 }
155
156 const path = `/${kept.join('/')}`
157
158 return place.home !== '' && path.startsWith(`${place.home}/`) ? `~${path.slice(place.home.length)}` : path
159}
160
161// 本機的路徑才接得上工作目錄,遠端的照原樣記
162const pathOn = (ctx: Context, ...parts: string[]): string =>
163 ctx.host === LOCAL ? anchored(ctx.place, [ctx.cwd, ...parts]) : (parts.at(-1) ?? '')
164
165const backupTo = (ctx: Context) => {
166 const target = plain(ctx.args).at(-1)
167
168 if (target !== undefined && BACKUP.test(target)) {
169 const path = pathOn(ctx, target)
170 add(ctx, 'backup', path, `rm ${path}`)
171 }
172}
173
174const RULES: Record<string, (ctx: Context) => void> = {
175 launchctl: ctx => {
176 const [verb, ...rest] = ctx.args
177 const labels = plain(rest).map(word => base(word).replace(/\.plist$/, ''))
178 const last = labels.at(-1)
179
180 if ((verb === 'bootstrap' || verb === 'load') && last !== undefined) {
181 add(ctx, 'launchd', last, `launchctl bootout gui/$(id -u)/${last}`)
182 }
183 if (verb === 'bootout' || verb === 'unload' || verb === 'remove') {
184 labels.forEach(label => drop(ctx, 'launchd', label))
185 }
186 },
187
188 systemctl: ctx => {
189 const scope = ctx.args.includes('--user') ? 'systemctl --user' : 'sudo systemctl'
190 const [verb, ...names] = plain(ctx.args)
191 const units = names.map(name => (name.includes('.') ? name : `${name}.service`))
192
193 if (verb === 'enable' || verb === 'start') {
194 units.forEach(unit => add(ctx, 'systemd', unit, `${scope} disable --now ${unit}`))
195 }
196 if (verb === 'disable' || verb === 'stop' || verb === 'mask') {
197 units.forEach(unit => drop(ctx, 'systemd', unit))
198 }
199 },
200
201 crontab: ctx => {
202 if (ctx.args.includes('-l')) {
203 return
204 }
205 if (ctx.args.includes('-r')) {
206 drop(ctx, 'cron', 'crontab')
207
208 return
209 }
210 add(ctx, 'cron', 'crontab', 'crontab -e')
211 },
212
213 docker: ctx => {
214 const [verb, ...rest] = plain(ctx.args)
215
216 if (verb === 'compose') {
217 const sub = rest.find(word => COMPOSE_VERBS.has(word))
218 const label = ctx.cwd === '' ? 'compose' : ctx.cwd
219 const there = ctx.cwd === '' ? '' : `cd ${ctx.cwd} && `
220
221 if (sub === 'up') {
222 add(ctx, 'docker', label, `${there}docker compose down`)
223 }
224 if (sub === 'down') {
225 drop(ctx, 'docker', label)
226 }
227
228 return
229 }
230
231 const isDetached = ctx.args.some(word => word === '--detach' || /^-[a-z]*d[a-z]*$/.test(word))
232
233 if (verb === 'run' && isDetached && !ctx.args.includes('--rm')) {
234 const name = valueAfter(ctx.args, '--name')
235
236 add(ctx, 'docker', name ?? UNNAMED, name === undefined ? 'docker ps' : `docker rm -f ${name}`)
237 }
238 if (verb === 'rm' || verb === 'stop') {
239 rest.forEach(name => drop(ctx, 'docker', name))
240 }
241 },
242
243 nohup: ctx => {
244 const label = base(plain(ctx.args)[0] ?? '')
245
246 if (label !== '') {
247 add(ctx, 'background', label, `pkill -f ${label}`)
248 }
249 },
250
251 tmux: ctx => {
252 const [verb] = ctx.args
253 const session = valueAfter(ctx.args, verb === 'kill-session' ? '-t' : '-s')
254
255 if (session === undefined) {
256 return
257 }
258 if ((verb === 'new' || verb === 'new-session') && ctx.args.includes('-d')) {
259 add(ctx, 'background', `tmux ${session}`, `tmux kill-session -t ${session}`)
260 }
261 if (verb === 'kill-session') {
262 drop(ctx, 'background', `tmux ${session}`)
263 }
264 },
265
266 gh: ctx => {
267 const [noun, verb, name] = plain(ctx.args)
268
269 if (noun !== 'repo' || name === undefined) {
270 return
271 }
272 if (verb === 'create') {
273 add(ctx, 'repo', base(name), `gh repo delete ${name} --yes`, GITHUB)
274 }
275 if (verb === 'delete') {
276 drop(ctx, 'repo', base(name), GITHUB)
277 }
278 },
279
280 git: ctx => {
281 const at = ctx.args.indexOf('worktree')
282 const verb = ctx.args[at + 1]
283
284 if (at < 0 || verb === undefined) {
285 return
286 }
287
288 // -b / -B 後面那個字是分支名,不是路徑
289 const rest = ctx.args.slice(at + 2).filter((word, index, all) => {
290 const before = all[index - 1]
291
292 return !word.startsWith('-') && before !== '-b' && before !== '-B'
293 })
294 const raw = rest[0]
295
296 if (raw === undefined) {
297 return
298 }
299
300 const inside = valueAfter(ctx.args.slice(0, at), '-C') ?? ''
301 const path = pathOn(ctx, inside, raw)
302
303 if (verb === 'add') {
304 add(ctx, 'repo', path, `git worktree remove ${path}`)
305 }
306 if (verb === 'remove') {
307 drop(ctx, 'repo', path)
308 }
309 },
310
311 cp: backupTo,
312 mv: backupTo,
313
314 rm: ctx => {
315 plain(ctx.args).forEach(path => drop(ctx, 'backup', pathOn(ctx, path)))
316 },
317}
318
319// 從一條 Bash 指令找出「留下了什麼」和「收掉了什麼」,照指令裡的順序排
320export const detect = (command: string, place: Place = { cwd: '', home: '' }): Detected => {
321 const out: Detected = { ops: [], dirs: [] }
322 const vars = new Map<string, string>()
323 let host = LOCAL
324 let cwd = ''
325
326 for (const words of segmentsOf(command)) {
327 const segment = expanded(words, vars)
328 const ssh = stripSsh(segment)
329
330 if (ssh !== undefined) {
331 host = ssh.host
332 cwd = ''
333 }
334
335 const run = ssh?.rest ?? segment
336
337 if (assignedInto(vars, run)) {
338 continue
339 }
340
341 const [head, ...args] = run
342
343 if (head === undefined) {
344 continue
345 }
346 if (head === 'cd' && args[0] !== undefined) {
347 cwd = args[0]
348 if (host === LOCAL) {
349 out.dirs.push(cwd)
350 }
351 continue
352 }
353 if (head === 'git' && host === LOCAL) {
354 const dir = valueAfter(args, '-C')
355
356 if (dir !== undefined) {
357 out.dirs.push(dir)
358 }
359 }
360
361 RULES[head]?.({ args, host, cwd, place, out })
362 }
363
364 return out
365}
366
367export const applyDetected = (
368 items: readonly Leftover[],
369 detected: Detected,
370 meta: Pick<Leftover, 'day' | 'session'>,
371): Leftover[] => {
372 let list = [...items]
373
374 for (const op of detected.ops) {
375 if ('drop' in op) {
376 list = list.filter(item => item.id !== op.drop)
377 continue
378 }
379
380 const id = idOf(op.add.kind, op.add.host, op.add.label)
381
382 if (!list.some(item => item.id === id)) {
383 list.push({ ...op.add, id, ...meta })
384 }
385 }
386
387 return list.slice(-200)
388}
389
390export const dayOf = (ms: number): string => {
391 const date = new Date(ms)
392 const pad = (part: number) => String(part).padStart(2, '0')
393
394 return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
395}
396
397// 本機排最前面,其餘照機器名,再照日期
398export const ordered = (items: readonly Leftover[]): Leftover[] =>
399 [...items].sort((a, b) => {
400 const rank = (item: Leftover) => `${item.host === LOCAL ? '' : item.host}\u0000${item.day}`
401
402 return rank(a) < rank(b) ? -1 : rank(a) > rank(b) ? 1 : 0
403 })
404
405export const summary = (t: Strings, items: readonly Leftover[], dirty: readonly DirtyRepo[]): string | undefined => {
406 const total = items.length + dirty.length
407
408 if (total === 0) {
409 return undefined
410 }
411
412 const backups = items.filter(item => item.kind === 'backup').length
413 const repos = items.filter(item => item.kind === 'repo').length
414
415 return t.summary(total, { services: items.length - backups - repos, backups, repos, dirty: dirty.length })
416}
417
418export const listText = (t: Strings, items: readonly Leftover[], dirty: readonly DirtyRepo[]): string => {
419 if (items.length === 0 && dirty.length === 0) {
420 return t.emptyList
421 }
422
423 const rows = items.map(
424 (item, index) =>
425 `${index + 1}. ${item.day} ${whatOf(t, item)} @ ${hostName(t, item.host)}\n${t.undoLine(item.undo)}`,
426 )
427 const repos = dirty.map(repo => t.repoLine(repo.label, repo.files))
428
429 return [summary(t, items, dirty) ?? '', ...rows, ...repos, '', t.footer].join('\n')
430}
431hooks/i18n.ts 144 lines1import type { PluginOptions } from 'claude-code'
2
3import type { LeftoverKind } from '../types'
4
5export type Lang = 'en' | 'zh-TW' | 'zh-CN'
6
7const LANGS: readonly Lang[] = ['en', 'zh-TW', 'zh-CN']
8
9export const langOf = (options: PluginOptions): Lang => LANGS.find(lang => lang === options.language) ?? 'en'
10
11const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
12
13type Counts = { services: number; backups: number; repos: number; dirty: number }
14
15const en = {
16 kind: {
17 launchd: 'launchd',
18 systemd: 'systemd',
19 cron: 'cron',
20 docker: 'docker',
21 background: 'background',
22 repo: 'repo',
23 backup: 'backup',
24 } as Record<LeftoverKind, string>,
25 local: 'local',
26 unnamed: 'unnamed container',
27 left: (n: number) => plural(n, 'leftover'),
28 undo: 'Clean up',
29 done: 'Done',
30 review: 'See changes',
31 ignore: 'Ignore',
32 uncommitted: (files: number) => `${plural(files, 'file')} uncommitted`,
33 files: (files: number) => plural(files, 'file'),
34 uncommittedHeading: 'Uncommitted',
35 nothingLeft: 'Nothing left behind.',
36 description: 'List what Claude left running or lying around',
37 argumentHint: '[drop <n>|clear]',
38 cleared: 'ledger cleared.',
39 summary: (total: number, { services, backups, repos, dirty }: Counts) =>
40 `${plural(total, 'leftover')}: ${[
41 services > 0 ? plural(services, 'service') : '',
42 backups > 0 ? plural(backups, 'backup') : '',
43 repos > 0 ? plural(repos, 'new repo') : '',
44 dirty > 0 ? `${plural(dirty, 'repo')} uncommitted` : '',
45 ]
46 .filter(part => part !== '')
47 .join(' · ')}`,
48 emptyList: 'nothing left behind.',
49 undoLine: (undo: string) => ` clean up: ${undo}`,
50 repoLine: (label: string, files: number) => `- ${label} ${plural(files, 'file')} uncommitted`,
51 footer: '/leftovers drop <n> removes one from the ledger, /leftovers clear empties it',
52 askToUndo: (what: string, host: string, day: string, undo: string) =>
53 `Clean up this item from leftovers: ${what} (${host}, created ${day}). Check that it still exists first, then remove it. This command should do it: ${undo}`,
54 askToReview: (label: string) => `Show me the uncommitted changes in ${label}. Just list them, don't commit.`,
55}
56
57export type Strings = typeof en
58
59const zhTW: Strings = {
60 kind: {
61 launchd: 'launchd',
62 systemd: 'systemd',
63 cron: 'cron',
64 docker: 'docker',
65 background: '背景程序',
66 repo: 'repo',
67 backup: '備份',
68 },
69 local: '本機',
70 unnamed: '未命名 container',
71 left: n => `留著 ${n} 項`,
72 undo: '收掉',
73 done: '完成',
74 review: '看改了什麼',
75 ignore: '忽略',
76 uncommitted: files => `${files} 檔未 commit`,
77 files: files => `${files} 檔`,
78 uncommittedHeading: '未 commit',
79 nothingLeft: '沒有留下的東西。',
80 description: '列出 Claude 留下還沒收掉的東西',
81 argumentHint: '[drop <編號>|clear]',
82 cleared: '帳本清空了。',
83 summary: (total, { services, backups, repos, dirty }) =>
84 `留著 ${total} 項:${[
85 services > 0 ? `${services} 服務` : '',
86 backups > 0 ? `${backups} 備份` : '',
87 repos > 0 ? `${repos} 新 repo` : '',
88 dirty > 0 ? `${dirty} repo 未 commit` : '',
89 ]
90 .filter(part => part !== '')
91 .join(' · ')}`,
92 emptyList: '沒有留下的東西。',
93 undoLine: undo => ` 收掉:${undo}`,
94 repoLine: (label, files) => `- ${label} ${files} 檔未 commit`,
95 footer: '/leftovers drop <編號> 從帳本拿掉,/leftovers clear 全清',
96 askToUndo: (what, host, day, undo) =>
97 `收掉 leftovers 記的這一項:${what}(${host},${day} 建的)。先確認它還在,再收掉,可以參考這個指令:${undo}`,
98 askToReview: label => `看一下 ${label} 還沒 commit 的改動是什麼,先列給我,不要 commit。`,
99}
100
101const zhCN: Strings = {
102 kind: {
103 launchd: 'launchd',
104 systemd: 'systemd',
105 cron: 'cron',
106 docker: 'docker',
107 background: '后台进程',
108 repo: '仓库',
109 backup: '备份',
110 },
111 local: '本机',
112 unnamed: '未命名容器',
113 left: n => `遗留 ${n} 项`,
114 undo: '清理',
115 done: '完成',
116 review: '查看改动',
117 ignore: '忽略',
118 uncommitted: files => `${files} 个文件未提交`,
119 files: files => `${files} 个文件`,
120 uncommittedHeading: '未提交',
121 nothingLeft: '没有遗留的东西。',
122 description: '列出 Claude 遗留、还没清理的东西',
123 argumentHint: '[drop <编号>|clear]',
124 cleared: '账本已清空。',
125 summary: (total, { services, backups, repos, dirty }) =>
126 `遗留 ${total} 项:${[
127 services > 0 ? `${services} 个服务` : '',
128 backups > 0 ? `${backups} 个备份` : '',
129 repos > 0 ? `${repos} 个新仓库` : '',
130 dirty > 0 ? `${dirty} 个仓库未提交` : '',
131 ]
132 .filter(part => part !== '')
133 .join(' · ')}`,
134 emptyList: '没有遗留的东西。',
135 undoLine: undo => ` 清理:${undo}`,
136 repoLine: (label, files) => `- ${label} ${files} 个文件未提交`,
137 footer: '/leftovers drop <编号> 从账本移除,/leftovers clear 全部清空',
138 askToUndo: (what, host, day, undo) =>
139 `清理 leftovers 记录的这一项:${what}(${host},${day} 创建)。先确认它还在,再清理,可以参考这个命令:${undo}`,
140 askToReview: label => `看一下 ${label} 还没提交的改动,先列给我,不要提交。`,
141}
142
143export const STRINGS: Record<Lang, Strings> = { en, 'zh-TW': zhTW, 'zh-CN': zhCN }
144types/index.d.ts 20 lines1export type LeftoverKind = 'launchd' | 'systemd' | 'cron' | 'docker' | 'background' | 'repo' | 'backup'
2
3export type Leftover = {
4 id: string
5 kind: LeftoverKind
6 host: string
7 label: string
8 undo: string
9 day: string
10 session: string
11}
12
13export type DirtyRepo = { path: string; label: string; files: number }
14
15declare module 'claude-code' {
16 interface PluginState {
17 leftovers: { items: Leftover[]; dirty: DirtyRepo[]; isOpen: boolean }
18 }
19}
20