/banben side pane of preview servers started by headless claude -p runs (port, effort level, done or running), using local lsof/ps; blocks opening more than 3…

English | 中文文档
cyxj-notch is a macOS notch dashboard for Claude Code: hover over the MacBook notch to see your 5-hour and weekly usage limits, every open Claude Code session (working / idle / just finished), task progress, prompt-cache countdown, and your content to-dos — fed by five small Claude Code mods that are included in this repo.
v0.1.0 · updated 2026-10-09 · tested on Claude Code 2.1.295 and macOS 26.7.1 · Changelog
When the mouse is away, the panel is exactly the size of the hardware notch, so you don't see it. Hover for 0.15 s and it grows out of the notch into a frosted-glass panel; move away and it tucks back in.
| Section | What you see | Data comes from |
|---|---|---|
| Usage | 5-hour and weekly limit used (%), when each resets | quota-status mod |
| Cache | The idle session whose prompt cache expires soonest ("12 min left") | quota-status mod |
| Sessions | Every open Claude Code session: running / idle / just finished, step 3/5 with a progress bar; click a session to jump to its Terminal.app tab | quota-status + task-progress mods |
| Versions | Preview servers started by headless claude -p runs, click to open | version-board mod |
| To-dos | Items from a Markdown log, "waiting on you" first, then "AI can continue", then "waiting until a time" | todo-pane mod |
| Publish cadence | Days since the last release and the next scheduled one | publish-pulse mod |
Design notes:
NSVisualEffectView glass, so there's no visible seam with the hardware.| Claude Code status line | cyxj-notch | |
|---|---|---|
| Where | Inside one terminal | Top of the screen, reachable from any app |
| Sessions shown | The one it's running in | Every open session, across terminals and projects |
| Usage limits (5 h / weekly) | Yes, via rate_limits | Yes, same data, written by the quota-status mod |
| Task progress, cache countdown, to-dos | Only what you script | Built in, from the five mods |
| Setup | One script in settings.json | Build the app + load five mods |
The two work together: quota-status keeps its own status line and also feeds the notch.
git clone https://github.com/chenyuxiaojin/cyxj-notch.git
cd cyxj-notch
./build.sh # swift build + package into build/刘海台.app
open build/刘海台.app # no Dock or menu-bar icon
pkill -x NotchDesk # quit
Then load the mods so the panel has something to show — add their folders to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (colon-separated, absolute paths):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/cyxj-notch/mods/quota-status:/path/to/cyxj-notch/mods/task-progress:/path/to/cyxj-notch/mods/version-board:/path/to/cyxj-notch/mods/todo-pane:/path/to/cyxj-notch/mods/publish-pulse",
"NOTCH_TODO_LOG": "/path/to/your/todo-log.md",
"NOTCH_PUBLISH_ROOT": "/path/to/your/publish-folder",
"NOTCH_TZ_OFFSET_HOURS": "8"
}
}
CLAUDE_CODE_PLUGIN_DIRS loads plugin folders the same way as --plugin-dir (docs); to try a mod for one session instead, run claude --plugin-dir mods/quota-status.
quota-status and task-progress need no configuration. The last three variables are optional; without them the to-do and publish sections simply stay hidden.
To tag "waiting on you" with your own name instead of 我:
defaults write com.xiaochen.notchdesk ownerName YourName
A Claude Code mod is a plugin with a hooks module: a JavaScript or TypeScript file whose functions (hooks) Claude Code calls on events such as session.start, turn.complete, tool.call and ui.render, and which can call the mod API ($.ui.status, $.ui.toast, $.fs.write, $.session.usage(), $.clock.every, $.tool.register, $.env.get, …). In an interactive session, mods loaded with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS reload when you save them. Official docs: overview · create a mod · reference · testing · loading plugins · example mods.
This repo is a working example of one pattern: mods write small JSON files, a native app reads them. Claude Code stays the source of truth; the notch app is read-only and never talks to Claude Code directly.
Claude Code session ──(mod hooks)──► ~/.claude/notch/*.json ──(poll every 2 s)──► notch app
| Mod | What it does inside Claude Code | What it writes for the notch |
|---|---|---|
quota-status | Status line with 5 h / weekly usage, reset day, session cost, cache countdown; toast at 90 % usage and 5 min before cache goes cold | sessions/<id>.quota.json every 60 s, marked ended on exit |
task-progress | Registers a progress tool so Claude reports multi-step work; draws a progress bar above the input box | sessions/<id>.progress.json on every change |
version-board | Side pane listing preview servers from headless claude -p runs, with effort level and done/running; blocks opening more than 3 at once | versions.json every 8 s |
todo-pane | /daiban side pane showing "in progress" and "published, wrapping up" from a Markdown log | todo.json every 5 min and after each turn |
publish-pulse | Status line with days since last publish (traffic light) and the next scheduled release | pulse.json every 10 min and after each turn |
Each mod has its configuration at the top of hooks/register.ts(x) and its own tests:
cd mods/quota-status
claude plugin validate . # checks the manifest and lists the events and API calls the mod uses
claude plugin test . # runs every *.test.ts / *.test.tsx; quota-status: 9 passed, 0 failed
All five mods pass both commands on Claude Code 2.1.295 (44 tests in total: quota-status 9, task-progress 11, version-board 13, todo-pane 2, publish-pulse 9).
Lessons from building these:
at timestamp; the app treats a session as closed after 150 s without an update, so crashes clean themselves up.quota.ts, bar.ts, scan.ts, parse.ts, pulse.ts have no engine calls, so they're unit-tested without Claude Code running._ or -, up to 64 characters (limits). On 2.1.295 /daiban registers but /待办 and /café are rejected at runtime, and claude plugin validate doesn't catch it.Everything lives in ~/.claude/notch/:
| File | Shape | |
|---|---|---|
sessions/<id>.quota.json | `{ id, cwd, at, working, cache, limits: [{ kind: "five_hour" \ | "seven_day", percentUsed, resetsAt }], tty?, app? }` |
sessions/<id>.progress.json | `{ id, at, progress: { done, total, now } \ | null }` |
versions.json | { at, rows: [{ label, port, effort, state, isRunning, isVersion }] } | |
todo.json | { at, busy: [{ title, next }], wrapUp: [{ title, next }] } | |
pulse.json | { at, text } |
The to-do log is plain Markdown with two sections. Each "next step" starts with who's up — 我: (you), AI:, or 等 10-13 14:59: (waiting until a time):
## 当前在忙
### Notch app open source
- 状态(10-09): screenshots done, README drafted.
- 下一步: 我: record a 30-second demo
- 入口: `projects/notch/README.md`
## 已发布待收尾
- Last video(10-01): add links on two platforms.
NOTCH_FEED=<folder with sample JSON> NOTCH_OPEN=1 ./build/刘海台.app/Contents/MacOS/NotchDesk
NOTCH_FEED points the app at another data folder; NOTCH_OPEN=1 opens the panel on launch and keeps it open, handy for screenshots.
Source layout:
Sources/NotchDesk/main.swift borderless NSPanel above the menu bar, hover tracking, notch geometry
Sources/NotchDesk/NotchView.swift SwiftUI panel, glass background, motion, all sections
Sources/NotchDesk/Feed.swift reads ~/.claude/notch/
mods/ the five Claude Code mods
docs/ screenshots and demo GIF
How do I see Claude Code usage limits on my Mac without opening a terminal? Run cyxj-notch with the quota-status mod. Hovering the notch shows your 5-hour and weekly percentages and their reset times, refreshed every 60 seconds.
What does "cache 12 min left" mean? Claude Code reuses a prompt cache while you keep talking. Each cache hit resets the timer; once a session sits idle past the lifetime, the next message re-sends the whole context — slower and more expensive. By default the main conversation gets 1 hour on a Pro/Max subscription within plan usage, and 5 minutes with an API key, a cloud provider, or usage credits beyond the plan. The mod assumes 1 hour when Claude Code reports usage windows and 5 minutes otherwise; it doesn't detect a custom promptCacheTtl, or the switch to 5 minutes once you go past plan usage. The panel shows the idle session closest to expiring, so you know which one to continue first.
Does it work without a notch? Yes. On a screen without a notch it uses a 200 pt-wide area at the top center of the main screen.
Does it send any data anywhere? No. The app only reads local files in ~/.claude/notch/; the mods only write there. No network calls.
Can I use only some of the mods? Yes. Each section hides itself when its file is missing or stale.
llms.txt lists every file worth reading in this repo, with one line each.
Write-up (in Chinese) on how it was designed and iterated: 给 MacBook 刘海装了个 Claude Code 面板.
Made by @cyxj_ai, a non-programmer building tools with Claude Code. More projects: cyxj-groksearch · cyxj-hyperframes · cyxj-remotion-starter
hooks/register.tsx 178 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5 MAX_VERSIONS,
6 buildBoard,
7 effortText,
8 finishedSince,
9 liveVersions,
10 parseLsof,
11 parsePs,
12 portsOf,
13 projectDirOf,
14 stateText,
15 usedFromTranscript,
16 versionLaunches,
17} from './scan'
18import type { Board, Row } from '../types'
19
20// ── 可配置项 ──
21// Claude Code 存对话记录的地方:~/.claude/projects(用来核实每个版本实际用的档位)
22const projectsDir = async ($: EngineInterface) => `${(await $.env.get('HOME')) ?? ''}/.claude/projects`
23// 刘海台读这里:~/.claude/notch/versions.json
24const feedPath = async ($: EngineInterface) => `${(await $.env.get('HOME')) ?? ''}/.claude/notch/versions.json`
25// 多久扫描一次
26const REFRESH_MS = 8000
27const PANE = 'versions'
28const COMMAND = 'banben'
29
30const board = atom({ plugin: 'version-board', key: 'board' } as const, { rows: [], at: 0 })
31
32// 跑完的版本档位从它自己的 transcript 里核实;同一份 transcript 只读一次
33const usedCache = new Map<string, { effort?: string; model?: string }>()
34
35const usedOf = async ($: EngineInterface, cwd: string) => {
36 const dir = `${await projectsDir($)}/${projectDirOf(cwd)}`
37 const files = (await $.fs.list(dir).catch(() => [])).filter(f => f.kind === 'file' && f.name.endsWith('.jsonl'))
38 const newest = [...files].sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
39 if (newest === undefined) return {}
40
41 const key = `${dir}/${newest.name}:${newest.mtimeMs}`
42 const hit = usedCache.get(key)
43 if (hit) return hit
44
45 const { stdout } = await $.process.run(['tail', '-c', '300000', `${dir}/${newest.name}`])
46 const used = usedFromTranscript(stdout)
47 usedCache.set(key, used)
48
49 return used
50}
51
52const scan = async ($: EngineInterface): Promise<Board> => {
53 const listen = await $.process.run(['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-a', '-c', 'node', '-Fpn'])
54 const ports = portsOf(parseLsof(listen.stdout))
55 const ps = parsePs((await $.process.run(['ps', '-ax', '-o', 'pid=,etime=,command='])).stdout)
56 const pids = [...new Set([...ports.keys(), ...ps.runs.map(run => run.pid)])]
57 const cwds = new Map<number, string>()
58
59 if (pids.length > 0) {
60 const out = await $.process.run(['lsof', '-a', '-p', pids.join(','), '-d', 'cwd', '-Fpn'])
61 for (const [pid, names] of parseLsof(out.stdout)) {
62 if (names[0] !== undefined) cwds.set(pid, names[0])
63 }
64 }
65
66 const used = new Map<string, { effort?: string; model?: string }>()
67 for (const pid of ports.keys()) {
68 const cwd = cwds.get(pid)
69 if (cwd !== undefined && !used.has(cwd)) used.set(cwd, await usedOf($, cwd).catch(() => ({})))
70 }
71
72 return buildBoard({ ports, cwds, ps, used, now: await $.clock.now() })
73}
74
75const sameRows = (a: Board, b: Board) => JSON.stringify(a.rows) === JSON.stringify(b.rows)
76
77const doneToast = (row: Row) =>
78 `${row.label}(${effortText(row) || '主工程'})跑完了${row.port === undefined ? '' : `,:${row.port} 可以看`}`
79
80const denyText = (live: Row[], adding: number) =>
81 `版本面板:已经开着 ${live.length} 个版本 Studio(${live.map(row => `:${row.port} ${row.label}`).join('、')}),` +
82 `这条命令还要再开 ${adding} 个,超过一次最多 ${MAX_VERSIONS} 版。先让用户挑完,关掉没选中的(lsof -ti :端口 | xargs kill)再开。`
83
84let hasOpened = false
85
86const refresh = async ($: EngineInterface) => {
87 const after = await scan($)
88 const before = await read($, board)
89
90 for (const row of finishedSince(before, after)) $.ui.toast(doneToast(row))
91 if (!sameRows(before, after)) await update($, board, () => after)
92
93 const rows = after.rows.map(row => ({
94 label: row.label,
95 port: row.port ?? null,
96 effort: effortText(row),
97 state: stateText(row),
98 isRunning: row.isRunning,
99 isVersion: row.isVersion,
100 }))
101 void $.fs.write(await feedPath($), JSON.stringify({ at: await $.clock.now(), rows })).catch(() => undefined)
102
103 // 第一次出现版本 Studio 时自己弹出面板(终端够宽才会停靠在侧边)
104 if (!hasOpened && liveVersions(after).length > 0) {
105 hasOpened = true
106 void $.ui.open({ id: PANE, title: '版本' }).catch(() => undefined)
107 }
108}
109
110export const register: Register = on => {
111 on('session.start', async ($, e, next) => {
112 if (!e.isInteractive) return next(e)
113
114 await $.command.register({ name: COMMAND, description: '侧边面板:开着的 Studio 版本 · 档位 · 跑完没' })
115 void refresh($).catch(() => undefined)
116 $.clock.every(REFRESH_MS, () => refresh($).catch(() => undefined))
117
118 return next(e)
119 })
120
121 on('command.run', { command: COMMAND }, async $ => {
122 hasOpened = true
123 await refresh($).catch(() => undefined)
124 const failed = await $.ui
125 .open({ id: PANE, title: '版本' })
126 .then(() => undefined)
127 .catch((error: unknown) => String(error))
128
129 return { text: failed === undefined ? '版本面板已打开。' : `版本面板没打开:${failed}` }
130 })
131
132 // 一次最多给你看 3 版:再开版本 Studio 会超过 3 个就拦下
133 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
134 const adding = versionLaunches(e.command)
135 if (adding === 0) return next(e)
136
137 const live = liveVersions(await scan($))
138 if (live.length + adding <= MAX_VERSIONS) return next(e)
139
140 return { deny: denyText(live, adding) }
141 }).catch(($, e, next) => next(e))
142
143 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
144 const { Box, Text, Link } = $.ui.resolve(e)
145 const { rows } = await read($, board)
146 const versions = rows.filter(row => row.isVersion)
147 const mains = rows.filter(row => !row.isVersion)
148 const live = versions.filter(row => row.port !== undefined).length
149
150 const line = (row: Row) => (
151 <Box key={row.cwd}>
152 <Text color={row.isRunning ? 'yellow' : 'green'}>{row.isRunning ? '⏳ ' : '● '}</Text>
153 {row.port === undefined ? (
154 <Text dimColor>{' — '}</Text>
155 ) : (
156 <Link href={`http://localhost:${row.port}`} label={`:${row.port}`} />
157 )}
158 <Text>{` ${row.label} `}</Text>
159 <Text color="claude">{effortText(row)}</Text>
160 <Text dimColor wrap="truncate-end">{` ${stateText(row)}`}</Text>
161 </Box>
162 )
163
164 return (
165 <Box flexDirection="column">
166 <Text bold>
167 版本 · 开着 {live} 个{live > MAX_VERSIONS ? `(超过 ${MAX_VERSIONS} 个了)` : ''}
168 </Text>
169 {versions.length === 0 && <Text dimColor>没有在跑或开着的版本。</Text>}
170 {versions.map(line)}
171 {mains.length > 0 && <Text bold>主工程</Text>}
172 {mains.map(line)}
173 <Text dimColor>点端口号打开 · 每 8 秒刷新</Text>
174 </Box>
175 )
176 })
177}
178hooks/scan.ts 178 lines1import type { Board, Row } from '../types'
2
3export type Run = { pid: number; minutes: number; effort?: string; model?: string }
4
5export const MAX_VERSIONS = 3
6
7const PORT_MIN = 3000
8const PORT_MAX = 3999
9
10// lsof -Fpn 的输出:p<pid> 开头一段,n<地址> 是监听地址或 cwd 路径
11export const parseLsof = (out: string): Map<number, string[]> => {
12 const byPid = new Map<number, string[]>()
13 let pid = 0
14
15 for (const line of out.split('\n')) {
16 if (line.startsWith('p')) {
17 pid = Number(line.slice(1))
18 if (!byPid.has(pid)) byPid.set(pid, [])
19 } else if (line.startsWith('n') && pid > 0) {
20 byPid.get(pid)?.push(line.slice(1))
21 }
22 }
23
24 return byPid
25}
26
27export const portsOf = (listen: Map<number, string[]>): Map<number, number> => {
28 const ports = new Map<number, number>()
29
30 for (const [pid, names] of listen) {
31 for (const name of names) {
32 const port = Number(/:(\d+)$/.exec(name)?.[1])
33 if (port >= PORT_MIN && port <= PORT_MAX && !ports.has(pid)) ports.set(pid, port)
34 }
35 }
36
37 return ports
38}
39
40// ps 的 etime:[[dd-]hh:]mm:ss
41export const etimeMinutes = (etime: string): number => {
42 const [days, rest] = etime.includes('-') ? etime.split('-') : ['0', etime]
43 const parts = (rest ?? '').split(':').map(Number).reverse()
44
45 return Number(days) * 1440 + (parts[2] ?? 0) * 60 + (parts[1] ?? 0)
46}
47
48const EFFORT = /--effort[=\s]+["']?(low|medium|high|xhigh|max)\b/
49const CODEX_EFFORT = /model_reasoning_effort\s*=\s*\\?["']?(low|medium|high|xhigh|minimal)\b/
50const MODEL = /--model[=\s]+["']?([\w.:-]+)/
51
52const isPrintRun = (command: string) =>
53 /(^|\/)claude\s/.test(command) && /\s(-p|--print)(\s|$)/.test(command)
54
55const isCodexRun = (command: string) => /(^|\/)codex\s+exec\b/.test(command)
56
57export type Ps = { commands: Map<number, string>; runs: Run[] }
58
59export const parsePs = (out: string): Ps => {
60 const commands = new Map<number, string>()
61 const runs: Run[] = []
62
63 for (const raw of out.split('\n')) {
64 const m = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(raw)
65 if (!m) continue
66
67 const pid = Number(m[1])
68 const command = m[3] ?? ''
69 commands.set(pid, command)
70
71 if (isPrintRun(command) || isCodexRun(command)) {
72 runs.push({
73 pid,
74 minutes: etimeMinutes(m[2] ?? '0:00'),
75 effort: (EFFORT.exec(command) ?? CODEX_EFFORT.exec(command))?.[1],
76 model: MODEL.exec(command)?.[1] ?? (isCodexRun(command) ? 'codex' : undefined),
77 })
78 }
79 }
80
81 return { commands, runs }
82}
83
84// -p 出的版本都在某个对话的 scratchpad 或 /tmp 下;其他目录里的是主工程
85export const isVersionDir = (cwd: string) => cwd.includes('/scratchpad/') || /^\/(private\/)?tmp\//.test(cwd)
86
87export const labelOf = (cwd: string) => {
88 const inPad = cwd.split('/scratchpad/')[1]
89 if (inPad) return inPad
90
91 return cwd.split('/').filter(Boolean).pop() ?? cwd
92}
93
94// ~/.claude/projects 下的文件夹名:路径里每个非字母数字都换成 -
95export const projectDirOf = (cwd: string) => cwd.replace(/[^A-Za-z0-9]/g, '-')
96
97const lastMatch = (text: string, re: RegExp) => [...text.matchAll(re)].pop()?.[1]
98
99export const usedFromTranscript = (tail: string) => ({
100 effort: lastMatch(tail, /"effort":"(low|medium|high|xhigh|max)"/g),
101 model: lastMatch(tail, /"model":"(claude-[\w.-]+)"/g),
102})
103
104export type Scan = {
105 ports: Map<number, number>
106 cwds: Map<number, string>
107 ps: Ps
108 used: Map<string, { effort?: string; model?: string }>
109 now: number
110}
111
112export const buildBoard = ({ ports, cwds, ps, used, now }: Scan): Board => {
113 const rows = new Map<string, Row>()
114
115 for (const [pid, port] of ports) {
116 const cwd = cwds.get(pid)
117 const command = ps.commands.get(pid) ?? ''
118 if (cwd === undefined || !/remotion/i.test(command)) continue
119
120 rows.set(cwd, { cwd, port, label: labelOf(cwd), isVersion: isVersionDir(cwd), isRunning: false, ...used.get(cwd) })
121 }
122
123 for (const run of ps.runs) {
124 const cwd = cwds.get(run.pid)
125 if (cwd === undefined || !isVersionDir(cwd)) continue
126
127 const row = rows.get(cwd) ?? { cwd, label: labelOf(cwd), isVersion: true, isRunning: false }
128 rows.set(cwd, {
129 ...row,
130 isRunning: true,
131 minutes: run.minutes,
132 effort: run.effort ?? row.effort,
133 model: run.model ?? row.model,
134 })
135 }
136
137 const list = [...rows.values()].sort((a, b) => (a.port ?? 99_999) - (b.port ?? 99_999))
138
139 return { rows: list, at: now }
140}
141
142export const liveVersions = (board: Board) => board.rows.filter(row => row.isVersion && row.port !== undefined)
143
144// 跑完的:上一轮还在跑、这一轮不跑了
145export const finishedSince = (before: Board, after: Board): Row[] => {
146 const running = new Set(before.rows.filter(row => row.isRunning).map(row => row.cwd))
147
148 return after.rows.filter(row => running.has(row.cwd) && !row.isRunning)
149 .concat(
150 before.rows.filter(row => row.isRunning && !after.rows.some(next => next.cwd === row.cwd)),
151 )
152}
153
154const QUIET = /^(p?kill|pgrep|grep|rg|ps|lsof|echo|cat|sed|head|tail)\b/
155
156// 这条命令要开几个「版本」Studio:只算 /tmp 或 scratchpad 下的,主工程不算
157export const versionLaunches = (command: string): number => {
158 if (!/\/tmp\/|scratchpad/.test(command)) return 0
159
160 const segments = command.split(/;|&&|\|\||\n/).map(s => s.trim().replace(/^[(\s]*(do\s+)?(nohup\s+)?/, ''))
161 const launches = segments.filter(s => /remotion\s+studio|\brun\s+dev\b/.test(s) && !QUIET.test(s)).length
162 if (launches === 0) return 0
163
164 const loop = /for\s+\w+\s+in\s+([^;\n]+?)\s*;?\s*do\b/.exec(command)
165 const items = loop?.[1]?.trim().split(/\s+/).length ?? 1
166
167 return launches * items
168}
169
170export const effortText = (row: Row) => row.effort ?? (row.isVersion ? '没写档位' : '')
171
172export const stateText = (row: Row) => {
173 if (row.isRunning) return `跑着 · ${row.minutes ?? 0} 分钟`
174 if (!row.isVersion) return '主工程'
175
176 return row.port === undefined ? '跑完 · 没开 Studio' : '跑完'
177}
178types/index.d.ts 19 lines1export type Row = {
2 cwd: string
3 label: string
4 isVersion: boolean
5 port?: number
6 effort?: string
7 model?: string
8 isRunning: boolean
9 minutes?: number
10}
11
12export type Board = { rows: Row[]; at: number }
13
14declare module 'claude-code' {
15 interface PluginState {
16 'version-board': { board: Board }
17 }
18}
19