SLOPSHOPPER

where-am-i

繁體中文版(fork 自 hamzafer/claude-code-mods 0.1.4):輸入框上方的進度摘要(goal、now、waiting、next),/where 顯示較長的回顧

newbandguardcommandpromptmodel
v0.1.4-tw.1MITupdated 2026-10-09StalicJi/my-mods/where-am-i
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · where-am-i
› 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 › /where ⎿ where-am-i: OK ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

my-mods

個人使用的 Claude Code mod marketplace。這裡只放通用的程式碼,網址、token 這類私人設定由每台電腦各自設定,不進版控。

mod用途
clean-view工具呼叫收成一行淡色摘要(Edit、Write、Bash 與錯誤訊息照常顯示),計畫清單顯示在輸入框上方;/clean 切換
where-am-i輸入框上方的進度摘要(繁體中文版,fork 自 hamzafer/claude-code-mods,MIT)
next-steps回合結束後建議下一步(繁體中文版,fork 自 anthropics/claude-plugins-community,Apache 2.0)
gitlab-sync分支跟遠端的同步狀態與還開著的張數(GitLab 的 Task、Issue 分開計數);GitLab 與 GitHub 上自己的 issue/MR(PR)有新動態時通知,/gitlab、/github 查看
agent-panel派出子代理時跳出面板(右側或輸入框上方),顯示每個子代理的模型、用量、估算費用與時間;/agents 開關

在新電腦安裝

clone 到固定的資料夾,再把那個資料夾加成 marketplace。這樣 Claude Code 會直接讀資料夾裡的程式,git pull 之後執行 /reload-plugins 就是新版:

git clone <這個 repo 的網址> ~/.claude/mods-marketplaces/my-mods
claude plugin marketplace add ~/.claude/mods-marketplaces/my-mods
claude plugin install clean-view@my-mods
claude plugin install where-am-i@my-mods
claude plugin install next-steps@my-mods
claude plugin install gitlab-sync@my-mods
claude plugin install agent-panel@my-mods

每台電腦各自的設定(gitlab-sync)

項目設定方式沒設定時
GitLab 網址在 Claude Code 執行 /plugin configure gitlab-sync,填入 gitlabUrl(例如 https://gitlab.example.com,不含 /api/v4);也可以執行 `echo '{"gitlabUrl":"https://gitlab.example.com"}' \claude plugin configure gitlab-sync@my-mods --values-stdin`不檢查 GitLab
GitLab token環境變數 GITLAB_TOKEN;或存進 macOS 鑰匙圈:security add-generic-password -U -a "$USER" -s gitlab-token -w。環境變數優先,是空的或被拒絕(401)時改讀鑰匙圈輸入框上方顯示「GitLab 通知暫停」(在 GitHub repo 裡不顯示:band 只顯示目前 repo 所屬平台的新動態與通知暫停)
GitHubgh auth login,token 跟 gh CLI 共用不檢查 GitHub

設定值存在這台電腦的 Claude Code 設定裡,不在這個 repo。

開發

改完任一個 mod:

claude plugin test <mod 資料夾>
claude plugin validate <mod 資料夾>
bash scripts/check-contracts.sh   # 改過任何 types/index.d.ts 後執行

可能有好幾個 Claude Code session 同時在改這裡的檔案,動手前先看 git status。

推送前的私人資訊檢查

scripts/git-hooks/pre-push 會在 git push 前掃描要送出去的 commit:新增的行、檔名、作者與提交者、commit 訊息。找到私有網段 IP,或私人關鍵字清單裡的字,就擋下推送。每個 clone 要做兩件事:

git config core.hooksPath scripts/git-hooks
# 這台電腦的私人關鍵字清單:一行一個延伸正規表示式,不分大小寫,# 開頭是註解
$EDITOR ~/.config/git/private-patterns
  • 清單本身就是私人資訊,只放在每台電腦,不進 repo。清單檔不存在、或裡面有寫錯的正規表示式時,一律擋下推送。
  • 確定是誤判時用 git push --no-verify。

授權

clean-view 與 gitlab-sync 採用根目錄的 MIT License。where-am-i(MIT)與 next-steps(Apache 2.0)是 fork,依各自資料夾裡的 LICENSE。

Source 2 files
hooks/register.tsx 275 lines
1// Where Am I: a live recap above the prompt (goal, now, waiting on you, next), plus /where.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { Recap } from '../types'
6
7const MODEL = 'haiku'
8const MAX_LOG = 20
9
10// 摘要區塊的外框,讓它在 band 裡一眼就能認出來;只框自己畫的部分,
11// 其他 mod 的內容(rest)留在框外,因為串接順序無法保證這個 mod 在最外層
12const BORDER_STYLE = 'round'
13const BORDER_COLOR = 'suggestion'
14// 外框左右各一格線、一格內距;框內每一列可用的寬度是 bodyColumns 扣掉這些,再扣掉標籤
15const BOX_CHROME_COLUMNS = 4
16const LABELS = { goal: '◆ Goal: ', now: '  Now: ', next: '  Next: ', wait: '  Wait: ' }
17// 存進 recap 前的安全上限:真正的截斷在畫框時依寬度做,這裡只防模型偶爾寫出一大段
18const MAX_STORED_COLUMNS = 300
19
20// Held by the host, so the recap survives a hot reload of this file.
21const recap = atom({ plugin: 'where-am-i', key: 'recap' } as const, null as Recap | null)
22const live = atom({ plugin: 'where-am-i', key: 'live' } as const, '')
23// True while the next-steps mod shows its list of next prompts: this band leaves out its own next meanwhile.
24const nextStepsActive = { plugin: 'next-steps', key: 'active' } as const
25// clean-view 的合併框模式(clean-view/hooks/register.tsx 的 combinedBox):
26// 合併框顯示時,Goal/Now/Wait 會跟計畫畫在同一個框裡,這裡就讓出位置。改名要兩邊一起改
27const cleanViewCombinedBox = { plugin: 'clean-view', key: 'combinedBox' } as const
28// clean-view 的計畫工具(clean-view/hooks/register.tsx 的 PLAN_TOOL):只是在回報計畫,不算正在做的事;
29// 也拿來判斷 clean-view 是否還載入著(見 isCleanViewLoaded)
30const CLEAN_VIEW_PLAN_TOOL = 'mcp__clean-view__update_plan'
31// 確認 clean-view 是否載入的結果留用多久
32const CLEAN_VIEW_RECHECK_MS = 2000
33
34// lastCheck 是最近一次確認的結果;isRecheckScheduled 表示已排了一次到期後的重新確認
35type CleanViewProbe = {
36  lastCheck: { isLoaded: boolean; checkedAt: number } | null
37  isRecheckScheduled: boolean
38}
39
40export const register: Register = on => {
41  let prompt = ''
42  let log: string[] = []
43  const cleanViewProbe: CleanViewProbe = { lastCheck: null, isRecheckScheduled: false }
44
45  on('session.start', async ($, e, next) => {
46    const r = await next(e)
47    await $.command.register({ name: 'where', description: 'Recap the session so far in a few bullets' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
48    return r
49  })
50
51  on('prompt.submit', async ($, e, next) => {
52    prompt = e.text.slice(0, 1500)
53    log = []
54    await update($, live, () => '正在讀你的訊息')
55    return next(e)
56  })
57
58  // Observe only: note what is happening, then let the call run untouched.
59  // Only Claude's own calls count: one another mod makes in the background (`$.tool.call`,
60  // `$.mcp.call`) is raised by that plugin, and `next.origin` names it instead of the engine.
61  on('tool.call', async ($, e, next) => {
62    if (next.origin.plugin !== 'engine' || e.tool === CLEAN_VIEW_PLAN_TOOL) return next(e)
63    const line = describe(e as unknown as Record<string, unknown>)
64    log = [...log, e.agentId ? `(agent) ${line}` : line].slice(-MAX_LOG)
65    if (!e.agentId) await update($, live, () => line)
66    const r = await next(e)
67    return r
68  })
69
70  on('turn.complete', async ($, e, next) => {
71    const r = await next(e)
72    if (!e.agentId) {
73      await update($, live, () => '')
74      void summarize($, prompt, log, e.answer).catch(() => {}) // in the background, so the turn ends at once
75    }
76    return r
77  })
78
79  on('command.run', { command: 'where' }, async $ => ({ text: await longRecap($, prompt, log) }))
80
81  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82    const rest = await next(e) // what other mods and Claude Code draw here stays
83    const r = await read($, recap)
84    if (e.props.hasSurvey || !r) return rest
85    const { value: combinedBoxMode = 'hidden' } = await $.state.get(cleanViewCombinedBox)
86    // 跟 clean-view 的 isCombinedBoxShown 同一個規則(兩個 mod 不能共用程式碼),改規則要兩邊一起改
87    const isCombinedBoxShown = combinedBoxMode === 'always' || (combinedBoxMode === 'whileWorking' && e.props.isWorking)
88    if (isCombinedBoxShown && (await isCleanViewLoaded($, cleanViewProbe))) return rest
89
90    const { Box, Text } = $.ui.resolve(e)
91    const { value: hasNextSteps = false } = await $.state.get(nextStepsActive)
92    const nextStep = hasNextSteps ? '' : r.next
93    // 依框的實際寬度截斷;wrap="truncate-end" 留著當保險,寬度算錯時也不會折成兩行
94    const innerColumns = e.props.bodyColumns - BOX_CHROME_COLUMNS
95    const fit = (label: string, text: string) => clip(text, innerColumns - displayWidth(label))
96
97    return (
98      <Box flexDirection="column">
99        <Box flexDirection="column" paddingX={1} borderStyle={BORDER_STYLE} borderColor={BORDER_COLOR}>
100          <Text wrap="truncate-end">
101            <Text color="cyan" bold>{LABELS.goal}</Text>
102            <Text>{fit(LABELS.goal, r.goal)}</Text>
103          </Text>
104          <Text wrap="truncate-end">
105            <Text dimColor>{LABELS.now}</Text>
106            <Text>{fit(LABELS.now, (await read($, live)) || r.now)}</Text>
107          </Text>
108          {nextStep !== '' && (
109            <Text wrap="truncate-end">
110              <Text dimColor>{LABELS.next}</Text>
111              <Text>{fit(LABELS.next, nextStep)}</Text>
112            </Text>
113          )}
114          {r.waiting !== '' && (
115            <Text color="yellow" wrap="truncate-end">{`${LABELS.wait}${fit(LABELS.wait, r.waiting)}`}</Text>
116          )}
117        </Box>
118        {rest}
119      </Box>
120    )
121  })
122}
123
124// clean-view 停用或載入失敗時,host 不會清掉它寫過的 state(只在 session 結束時整批清),
125// combinedBox 可能一直停在 always,摘要框就永遠讓位。host 卸載 plugin 時會一併移除它註冊的工具,
126// 所以改看計畫工具還在不在。列工具要算每個工具的描述,不適合每次重畫都做:結果留用一段時間,
127// 期間的重畫改排一次到期後的重新確認,結果變了就重畫,停用後不必等別的事件來觸發重畫
128async function isCleanViewLoaded($: EngineInterface, probe: CleanViewProbe): Promise<boolean> {
129  const now = await $.clock.now()
130  const last = probe.lastCheck
131  if (last === null || now - last.checkedAt >= CLEAN_VIEW_RECHECK_MS) {
132    const isLoaded = await listsCleanViewTool($)
133    probe.lastCheck = { isLoaded, checkedAt: now }
134    return isLoaded
135  }
136  if (!probe.isRecheckScheduled) {
137    probe.isRecheckScheduled = true
138    $.clock.after(last.checkedAt + CLEAN_VIEW_RECHECK_MS - now, async () => {
139      probe.isRecheckScheduled = false
140      const isLoaded = await listsCleanViewTool($)
141      const hasChanged = isLoaded !== probe.lastCheck?.isLoaded
142      probe.lastCheck = { isLoaded, checkedAt: await $.clock.now() }
143      if (hasChanged) $.ui.invalidate('ui.render')
144    })
145  }
146  return last.isLoaded
147}
148
149// 列不出工具時當作還載入著,維持照 combinedBox 讓位的原行為,避免兩個框同時出現
150async function listsCleanViewTool($: EngineInterface): Promise<boolean> {
151  const tools = await $.tool.list().catch(() => null)
152  return tools === null || tools.some(tool => tool.name === CLEAN_VIEW_PLAN_TOOL)
153}
154
155// A short label for one tool call: what a person would say Claude is doing.
156// 用字跟 clean-view 的 describe 一致,同一個畫面上 transcript 與 Now 才不會兩套說法
157export function describe(e: Record<string, unknown>): string {
158  const tool = String(e.tool)
159  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
160  const file = (p: string) => p.split('/').slice(-2).join('/')
161  if (tool === 'Bash') return `執行:${s('description') || s('command').slice(0, 60)}`
162  if (tool === 'Read') return `讀取 ${file(s('file_path'))}`
163  if (tool === 'Write') return `寫入 ${file(s('file_path'))}`
164  if (tool === 'Edit') return `編輯 ${file(s('file_path'))}`
165  if (tool === 'NotebookEdit') return `編輯 ${file(s('notebook_path'))}`
166  if (tool === 'Grep' || tool === 'Glob') return `搜尋 "${s('pattern').slice(0, 40)}"`
167  if (tool === 'WebSearch') return `搜尋網路「${s('query').slice(0, 40)}」`
168  if (tool === 'WebFetch') return `讀取網頁 ${s('url').replace(/^[a-z]+:\/\//i, '').split(/[/?#]/)[0]}`
169  if (tool === 'Agent') return `委派 agent:${s('description')}`
170  if (tool === 'Skill') return `使用 skill:${s('skill')}`
171  if (tool === 'AskUserQuestion') return '詢問你問題'
172  if (tool === 'ToolSearch') return '載入工具'
173  if (tool.startsWith('mcp__')) return `使用 ${tool.split('__').slice(1).join(' ')}`
174  return `使用 ${tool}`
175}
176
177async function summarize($: EngineInterface, prompt: string, log: string[], answer: string) {
178  const before = await read($, recap)
179  const r = await $.model.complete({
180    model: MODEL,
181    maxTokens: 300,
182    system:
183      'You keep a one-glance recap of a coding session for someone with ADHD. ' +
184      'Write every field in Traditional Chinese as used in Taiwan (繁體中文,台灣用語); keep code identifiers, ' +
185      'file names and commands as they are. Plain words, no em dashes, ' +
186      'each field at most 30 Chinese characters. Reply with JSON only: {"goal","now","waiting","next"}. ' +
187      '"goal": the overall aim of the session (keep the previous goal unless it clearly changed). ' +
188      '"now": what was just done. "waiting": what the assistant is waiting on from the person, or "". ' +
189      '"next": the next step.',
190    prompt: [
191      `Previous recap: ${before ? JSON.stringify(before) : 'none'}`,
192      `The person's latest message: ${prompt}`,
193      `Tools used this turn: ${log.join('; ') || 'none'}`,
194      `The assistant's reply: ${answer.slice(0, 2500)}`,
195    ].join('\n\n'),
196  })
197  if (!r.isAnswered) return
198  const parsed = parseRecap(r.text)
199  if (parsed) await update($, recap, () => parsed)
200}
201
202async function longRecap($: EngineInterface, prompt: string, log: string[]) {
203  const messages = (await $.session.messages()).slice(-12)
204  const r = await $.model.complete({
205    model: MODEL,
206    // 中文每個字耗用的 token 比英文多,原本的 500 寫 6 點條列可能被截斷
207    maxTokens: 700,
208    system:
209      'Write a recap of this coding session for someone who lost track, in Traditional Chinese as used in Taiwan ' +
210      '(繁體中文,台灣用語); keep code identifiers, file names and commands as they are. Plain words, no em dashes. ' +
211      'At most 6 short bullets: the goal, what is done, what is happening now, what is waiting on them, the next step.',
212    prompt: [
213      `<transcript>\n${messages.filter(m => m.text.trim() !== '').map(m => `[${m.role === 'user' ? 'person' : 'assistant'}] ${m.text.slice(0, 800)}`).join('\n')}\n</transcript>`,
214      `Latest message: ${prompt}`,
215      `Recent tool calls: ${log.join('; ') || 'none'}`,
216      'Write the recap of the transcript above now: the bullets only, not a reply to it.',
217    ].join('\n\n'),
218  })
219  return r.isAnswered ? r.text : '目前無法產生摘要。'
220}
221
222// 東亞寬字元、全形符號與 emoji 在終端機佔兩格;範圍用 \u 跳脫,避免存檔時字元被正規化成別的碼位
223const WIDE_CHAR = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
224
225// 併成一行、去掉結尾的句號(刪節號不算),再以顯示寬度截到 maxColumns 格以內,放不下補「…」。
226// 以前只留第一句,模型多寫的句子會整句消失又沒有「…」提示,現在每一句都留著,放不下才截。
227// 原版用空格找斷點,中文沒有空格會切錯位置,所以改成依顯示寬度計算
228export function clip(text: string, maxColumns: number) {
229  const oneLine = text.replace(/\s+/g, ' ').trim().replace(/(?<![.。])[.。]$/, '')
230  if (displayWidth(oneLine) <= maxColumns) return oneLine
231  if (maxColumns <= 0) return ''
232  return `${trimToWordBoundary(takeWidth(oneLine, maxColumns - 1))}…`
233}
234
235function charWidth(char: string) {
236  return WIDE_CHAR.test(char) ? 2 : 1
237}
238
239function displayWidth(text: string) {
240  let width = 0
241  for (const char of text) width += charWidth(char)
242  return width
243}
244
245function takeWidth(text: string, maxWidth: number) {
246  let width = 0
247  let kept = ''
248  for (const char of text) {
249    width += charWidth(char)
250    if (width > maxWidth) break
251    kept += char
252  }
253  return kept
254}
255
256// 結尾是英文單字時退回最後一個空格,避免切在單字中間;中文字與字之間本來就能斷
257function trimToWordBoundary(kept: string) {
258  const lastSpace = kept.lastIndexOf(' ')
259  const tail = kept.slice(lastSpace + 1)
260  return lastSpace > 0 && !WIDE_CHAR.test(tail) ? kept.slice(0, lastSpace) : kept
261}
262
263// The model's JSON, tolerating a code fence around it.
264export function parseRecap(text: string): Recap | null {
265  const body = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
266  try {
267    const o = JSON.parse(body) as Record<string, unknown>
268    const field = (k: string) => (typeof o[k] === 'string' ? clip((o[k] as string).replace(/\s*—\s*/g, ', '), MAX_STORED_COLUMNS) : '')
269    if (!field('goal')) return null
270    return { goal: field('goal'), now: field('now'), waiting: field('waiting'), next: field('next') }
271  } catch {
272    return null
273  }
274}
275
types/index.d.ts 20 lines
1export type Recap = { goal: string; now: string; waiting: string; next: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'where-am-i': { recap: Recap | null; live: string }
6    // next-steps 擁有,這裡只讀 active:清單顯示時讓出摘要框裡的「Next:」。
7    // 形狀要跟 next-steps/types/index.d.ts 一致,../scripts/check-contracts.sh 會抓出不一致
8    'next-steps': { active: boolean }
9    // clean-view 擁有,這裡只讀 combinedBox。形狀要跟 clean-view/types/index.d.ts 一致,
10    // ../scripts/check-contracts.sh 會抓出不一致
11    'clean-view': {
12      isEnabled: boolean
13      plan: { title: string; status: 'pending' | 'in_progress' | 'completed' }[]
14      tick: number
15      turnClock: { startedAt: number; endedAt: number | null } | null
16      combinedBox: 'hidden' | 'whileWorking' | 'always'
17    }
18  }
19}
20