SLOPSHOPPER

leftovers

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

newbandrowsguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · leftovers
› fix the failing auth test and add an audit log call ⏺ 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 › /leftovers ⎿ leftovers: nothing left behind. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<h1 align="center">Awesome Claude Code Mods</h1>

<a id="showcase"></a>

Mod showcase

<table> <tr><td width="33%" align="center" valign="top"><p><a href="https://github.com/therahul-yo/clawdman"><img src="assets/showcase-01.gif" width="100%" alt="Clawdman: An animated companion that reacts to your coding session. Demo · 10×."></a></p><p><strong><a href="https://github.com/therahul-yo/clawdman">Clawdman</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/pi-agent/"><img src="assets/showcase-02.gif" width="100%" alt="Pi Agent: Run pi-powered models as native Claude Code subagents. Demo · 10×."></a></p><p><strong><a href="AgentMods/pi-agent/">Pi Agent</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="FocusMods/breathing-exercises/"><img src="assets/showcase-03.gif" width="100%" alt="Breathing Exercises: A guided breathing animation while Claude works."></a></p><p><strong><a href="FocusMods/breathing-exercises/">Breathing Exercises</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="sources/creative-toolkit/"><img src="assets/showcase-04.png" width="100%" alt="Creative Toolkit: A collection of live panels, session tools and playful mods. Code Pet screenshot."></a></p><p><strong><a href="sources/creative-toolkit/">Creative Toolkit</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/agent-cartoons/"><img src="assets/showcase-05.gif" width="100%" alt="Agent Cartoons: Coding activity becomes a cartoon in a choice of visual styles."></a></p><p><strong><a href="GameMods/agent-cartoons/">Agent Cartoons</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="https://github.com/adamholter/claude-subway-surfers"><img src="assets/showcase-06.gif" width="100%" alt="Subway Surfers Desktop: Gameplay beside Claude Desktop while it works. Desktop demo · 10×."></a></p><p><strong><a href="https://github.com/adamholter/claude-subway-surfers">Subway Surfers Desktop</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="GameMods/dino-game/"><img src="assets/showcase-07.gif" width="100%" alt="Dino Game: Play the T-rex runner above your prompt."></a></p><p><strong><a href="GameMods/dino-game/">Dino Game</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/spell-bar/"><img src="assets/showcase-08.gif" width="100%" alt="Spell Bar: An animated spell bar for your selected effort level."></a></p><p><strong><a href="GameMods/spell-bar/">Spell Bar</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/agent-dashboard/"><img src="assets/showcase-09.gif" width="100%" alt="Agent Dashboard: A live dashboard for context, costs, permissions and agents."></a></p><p><strong><a href="AgentMods/agent-dashboard/">Agent Dashboard</a></strong></p></td></tr> </table>

<a id="browse-by-category"></a>

<h2>Browse by category <img src="assets/category-magnifier.gif" width="52" height="40" alt="Clawd with a magnifying glass"></h2>

Usage Mods · Agent Mods · Planning Mods · File Mods · Git Mods · Safety Mods · Memory Mods · Interface Mods · Prompt Mods · Testing Mods · Web Mods · Focus Mods · Game Mods · Integration Mods

Source packages available here: 82 mods from 38 repositories, with their complete tracked files and notices. Local package links open inside this collection. Other entries currently lead to upstream references while the wider source import is reviewed.

Entries are grouped by their main purpose. Collections can contain several kinds of mods; hosted mod pages are categorized individually.

<a id="usage"></a>

Usage Mods

Browse Usage Mods

Track model usage, costs, effort and quotas.

  • Auto Effort · arthur-fontaine - Let Jev select Claude's reasoning effort for each prompt, adapting the configured effort level to the incoming request rather than choosing it manually every time.
  • Automatic Model and Effort Router · sofanaja44 - Choose a Claude model and reasoning effort according to task difficulty, with rules that limit unnecessary prompt-cache rewrites. Cost and cache statistics help you follow the routing decisions.
  • Braille Usage · kawase1295 - Check your current model, context usage and five-hour or weekly limits in compact text-based meters above the prompt.
  • Budget and Context Toolkit · Arunjay4213 - Track session spending, subscription quotas, cache use and per-turn token costs. Set budget limits that warn before exhaustion and stop tool calls when a configured limit is exceeded.
  • Budget Governor · ccdwyer - Set session and daily spending caps, monitor them above the prompt, and receive a wrap-up reminder at eighty percent. New prompts are refused once the cap is reached.
  • Cache, Context and Cost Panel · anthonyhungnguyen - Track token spending for the session and recent days, get context-limit warnings, and see what rebuilding the prompt cache cost. A completion alert announces long turns.
  • Clawd Usage Meters · Toshkee - Watch live context, rate-limit and cost meters above the prompt, accompanied by Clawd's wand animations and banner tricks as you work in Claude Code.
  • Cockpit and Bodyguard · Para-FR - Inspect session turns, tools, tokens, durations and changed files in a live cockpit. A companion guard blocks access to .env files and destructive Bash commands, displaying blocked-action alerts.
  • Context and Quota Forecast · imsalik - See a context forecast alongside five-hour and weekly rate-limit usage above the prompt. Reset countdowns help you understand when each plan allowance becomes available again.
  • Context Saver · AlmogBaku - Spot repeated tool calls and wasteful workflows during a Claude Code session. Review their measured time and context cost, then send a suggested correction with one click.
  • Context Weather and Usage · travisoa - Read context fullness as changing weather symbols alongside token totals and quota consumption. Recent-turn bars show how the conversation grew, helping you spot increasing pressure on the context window.
  • Cost and Output Token Band · Luma-Sa - Keep five-hour and seven-day usage limits, session cost and output-token totals visible in a band above the Claude Code prompt while you work.
  • Cost Meter · zaferayan - Track API-equivalent session cost above the prompt, including subagent usage, with a configurable budget warning. On subscription plans, the displayed amount estimates API pricing rather than your bill.
  • Desktop Next Steps and Limits · shichang4fun - See quota limits and reset countdowns above the prompt in Claude Code Desktop. After each turn, suggested next prompts can be sent with a click or copied for editing.
  • Desktop Session Status Bar · sgmonda - Keep five-hour and weekly usage limits, reset times, session clocks and turn duration visible in Claude Code Desktop. The bar also lists active subagents, background jobs and scheduled wakeups.
  • Desktop SVG Usage Meters · s-hiraoku - See context use and the five-hour and seven-day quota windows as SVG meters above the prompt in Claude Code Desktop. The compact display keeps capacity visible while you work.
  • Effort Guard · stefanochieli - Track context and token usage, review an effort log for each turn and receive an escalation alert after repeated command or test failures. It observes activity without blocking it.
  • Effort Router · totally-tim - Choose reasoning effort for each turn with Jev or a compatible classifier, and display the selected level. Shadow mode records the recommendation; enforce mode applies it to the session.
  • Effort Shortcut · daanqq - Cycle Claude's reasoning effort through low, medium, high, and extra-high with one command. Bind that command to a key to change effort quickly while skipping the maximum setting.
  • Focus Band · chaoshengsc - Keep your five-hour and weekly plan usage visible above the prompt. Progress bars, percentages, and reset times help you see how much of each allowance you have used.
  • Jet Router · jetsongdev - Experiment with per-turn reasoning effort recommendations while keeping your chosen model. Use fixed test decisions or opt into Jev classification, with observation and optional application modes.
  • Jev Claude Router · Flam1ngFir3ball - Let Jev choose the model and reasoning effort for each turn while considering the cost of switching. It also provides Jev-guided context compaction.
  • Jev Model and Effort Selection · unclecode - Observe or pin model and effort choices, or opt into automatic selection with Jev. The experimental router lets you compare recommendations before allowing them to steer the session.
  • Jev Model Router · satviksinha - Choose a model and reasoning effort for each turn using TypeSafe's Jev, through its direct API or Vercel AI Gateway. A visible routing decision shows which model will handle the work.
  • Jev Route · drewpayment - Choose a Claude model for each turn using Jev's estimate of what the task needs. Toggle routing with /route to balance model capability against cost.
  • Limitpace · fstandhartinger - Track subscription limits and spending pace through live usage bars. An optional delegation adviser can suggest using another provider as part of managing your available capacity.
  • Model and Effort Router · moritalous - Choose reasoning effort for each prompt while keeping the model fixed, or enable a separate router that selects Haiku, Sonnet, or Opus using Jev. Both tools adjust resources per turn.
  • Model Shortcuts · richkuo - Use keyboard shortcuts to switch models and adjust reasoning effort, with the active choices displayed in the prompt footer.
  • Netrunner HUD · ccdwyer - Watch context use, token activity, tool calls, costs, and job status in an animated cyberpunk dashboard. Git and test widgets keep related session information alongside the live usage gauges.
  • Next Steps and Usage Band · pawandeepdhall - Keep five-hour and weekly usage limits visible with reset countdowns, and choose from suggested next steps after each reply. Buttons also provide shortcuts for starting a new chat or pushing work.
  • Oxide Jev Model Router · Kiy-K - Use Jev to choose subagent models and adjust the main conversation's model and reasoning effort as work progresses. A built-in classifier provides a fallback without an API key.
  • Plan and Context Usage Line · KhadeerBasha1232 - Track five-hour and weekly plan limits, reset times, context usage, the active model and session cost in one line above the Claude Code prompt.
  • Plan Usage and Reset Times · jumoog - Keep five-hour and weekly plan usage visible above the prompt, together with each reset time. Check the current allowance windows without opening a separate usage screen.
  • Power Bottom Status Line · brianSchanbacher - See the model, folder, branch, context fill, cost, elapsed time, cache hits and live agents in a cross-platform status line at the bottom of Claude Code.
  • Project Spend and Cache Status · Sma1lboy - Track project spending alongside context usage, cache hits and the cache countdown. Inspect why a turn missed the cache, and open the project ledger with /usage.
  • Prompt Enhancement and Telemetry · MiguelMachado-dev - Inspect per-turn duration, first-token latency, token throughput, requests and stalls in a live band. An additional command rewrites a draft prompt with Sonnet and returns it for your review.
  • Prompt Footer Usage · WoBok - Keep context usage and rate-limit consumption visible in Claude Code's prompt footer, so you can check those readings without opening a separate usage view.
  • Prompt Usage Timeline · augiefra - Compare five-hour and weekly usage with elapsed time, inspect token bars for recent prompts and watch context status above the prompt. Labels are available in English or French.
  • Quota and Cost Pills · Sh0ckWaveZero - Track five-hour and seven-day rate limits, session tokens and cost in compact indicators above the prompt. The band can sit alongside other status bands.
  • Quota Reserve · chrisns - Reserve part of your five-hour and weekly quota for later work. The mod pauses at configured thresholds, asks whether to continue, and releases the reserve shortly before the reset.
  • Rate-Limit History Band · mohammed-alsalhi - Follow rate-limit consumption since each window last reset, including five-hour, weekly, and per-model weekly allowances. A band above the prompt graphs how usage accumulates over time.
  • Retro Usage Band · iamkhalid2 - Keep your plan's five-hour and weekly usage limits in view through a slim retro-style band above the prompt. Check both allowances as you work.
  • Sessclone · NotTahaAli - Send Claude Code usage to a SessClone deployment so laptop, cloud and CI sessions can contribute to a shared team ledger of usage and cost.
  • Session and Weekly Usage Band · Gooner44 - See session, weekly and Fable 5.1 usage together in a band above the Claude Code prompt, keeping these separate usage readings visible while you work.
  • Session Cockpit · nvr0x5 - Track context, spending, usage limits, reset countdowns and burn rates above the prompt. Plan progress and active subagents share the same cockpit, with display options for terminal and Desktop sessions.
  • Session Cost and Remaining Usage · HydrowZer - See the current session cost and remaining usage in a band above the Claude Code prompt, keeping spending and available capacity visible beside your conversation.
  • Session Dashboard and Alerts · lucenity0 - Follow turn durations, token counts, files touched, and tool activity through a timer and dashboard. Companion mods notify you when long turns finish and block destructive commands or edits to secrets.
  • Session Internals Dashboard · tomstagl - Inspect context, tokens, cost, limits, tools and agents in a live side-pane dashboard. An accompanying insights interface lets the session answer questions using the displayed measurements.
  • Session Sidebar · richard-parayno - Toggle a sidebar showing usage limits, context size, prompt-cache expiry, current activity and touched files. It keeps the session's resource picture visible beside the conversation.
  • Stack and Wait Time · ShahriarBijoy - Follow tasks, subagents, rate limits and monthly cost in a stacked progress band. A companion vocabulary game uses German words related to the session's topic while Claude works.
  • Terminal Usage Monitor · ilkayGkbdk - Monitor context fill, rate limits, token speed, cost, and active subagents in a live terminal dashboard. The display brings the session's resource use together in one place.
  • Turn Receipts and Error Cues · roaringsoul404 - Receive a receipt after each turn listing touched files, commands, tokens and cost. A companion animation turns the spinner into a pixel-art failure cue when a tool call fails.
  • Usage and Active Agent Dashboard · quango2304 - Follow usage bars, limits, session cost, cache expiry and active time alongside the agents currently running. The combined display makes resource consumption visible while work is underway.
  • Usage and Cache Warmth Band · Vosssa - See five-hour and weekly usage, context fill and prompt-cache warmth together above the Claude Code prompt, keeping the main resource readings visible while you work.
  • Usage and Spend Chips · kreddevils18 - Track five-hour and weekly allowances, session cost, and spending history through colored chips above the prompt. Open /usage-mod for a fuller view of the usage details.
  • Usage Line · Steven-ODell - See five-hour, weekly and context usage in the prompt footer, including whether consumption is ahead of or behind an even pace through the usage window.
  • Usage Log · tanuu5 - Record five-hour and weekly usage after each turn, then compare consumption with your chosen pace in a graph. The separate Clawd Dance mod can also show that pacing above the prompt.
  • Usage Report · Schweem - Monitor context usage, five-hour and weekly quotas, and session cost in the status line, with warnings as you approach your limits.
  • Usage Wrapup · DaKev - Ask running agents to wrap up when your remaining plan allowance gets low, helping them finish their current work before the usage limit interrupts it.
  • Weekly Usage and Spend Board · aycandv - Compare spending by model with a weekly-limit pace gauge, using a sliding ticker in the terminal or a strip and details pane in the desktop app.

<a id="agents"></a>

Agent Mods

Browse Agent Mods

Coordinate agents and follow their actual work.

  • 5dive Telemetry Toolkit · 5dive-ai - Connect Claude Code sessions to 5dive's task and gate controls, with a panel showing assignments and token budgets. Optional telemetry, compaction and tool-call policies support coordinated agent work.
  • Agent Control and Document Desk · masahide - Send messages and interruption requests to Claude through the agentctl command line and collect acknowledgments. A companion document desk gathers questionnaire answers and detailed document-review comments in a browser, then delivers them to Claude.
  • Agent Dashboard · scasella - Monitor the main agent and its subagents in a live dashboard that brings together model usage, permission requests, task activity and session logs.
  • Agent Flow · Charlie0113-T - Open /flow to follow a live tree of the session's subagents and teammates beside the transcript. A text view is available where panes cannot be displayed.
  • Agent Models and Peek · alex2481kobe - Show each subagent's model and reasoning effort beside its task in the native agent list. A companion viewer displays images inline, using real pixels or a terminal-compatible block
Source 4 files
hooks/register.tsx 417 lines
1import { 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}
417
hooks/detect.ts 431 lines
1import 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}
431
hooks/i18n.ts 144 lines
1import 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 }
144
types/index.d.ts 20 lines
1export 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