SLOPSHOPPER

publish-pulse

Status line showing days since your last publish (traffic light) and the next scheduled release, read from Markdown files under NOTCH_PUBLISH_ROOT; also writes…

newstatustimer
★ 1v0.1.0MITupdated 2026-10-09chenyuxiaojin/cyxj-notch/mods/publish-pulse
A shopper browsing a rack in a slop shop
README

English | 中文文档

cyxj-notch

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.

License: MIT Platform Claude Code YouTube

v0.1.0 · updated 2026-10-09 · tested on Claude Code 2.1.295 and macOS 26.7.1 · Changelog


What it does

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.

SectionWhat you seeData comes from
Usage5-hour and weekly limit used (%), when each resetsquota-status mod
CacheThe idle session whose prompt cache expires soonest ("12 min left")quota-status mod
SessionsEvery open Claude Code session: running / idle / just finished, step 3/5 with a progress bar; click a session to jump to its Terminal.app tabquota-status + task-progress mods
VersionsPreview servers started by headless claude -p runs, click to openversion-board mod
To-dosItems from a Markdown log, "waiting on you" first, then "AI can continue", then "waiting until a time"todo-pane mod
Publish cadenceDays since the last release and the next scheduled onepublish-pulse mod

Design notes:

  • Collapsed = the notch itself (220 × 38 pt on a 16" MacBook Pro), no extra "ears", so it never looks like something stuck on top of the menu bar.
  • The strip touching the notch is pure black and fades into NSVisualEffectView glass, so there's no visible seam with the hardware.
  • Opening uses a slightly bouncy spring and staggers the sections in (fade + 8 pt drop + blur); closing is faster, with no bounce. Exits are shorter than entrances.

How it compares

Claude Code status linecyxj-notch
WhereInside one terminalTop of the screen, reachable from any app
Sessions shownThe one it's running inEvery open session, across terminals and projects
Usage limits (5 h / weekly)Yes, via rate_limitsYes, same data, written by the quota-status mod
Task progress, cache countdown, to-dosOnly what you scriptBuilt in, from the five mods
SetupOne script in settings.jsonBuild the app + load five mods

The two work together: quota-status keeps its own status line and also feeds the notch.

Requirements

  • macOS 14 or later (a notched MacBook is best; on other screens it uses a 200 pt "virtual notch" at the top center)
  • Swift 5.9+ (Xcode or Command Line Tools)
  • Claude Code v2.1.287 or later, where mods are on by default (tested on 2.1.295)
  • A Pro or Max subscription for the usage section: Claude Code only reports the 5-hour and weekly windows to subscribers, and only after the session's first response (docs)

Quick start

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

Claude Code mods in practice

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
ModWhat it does inside Claude CodeWhat it writes for the notch
quota-statusStatus line with 5 h / weekly usage, reset day, session cost, cache countdown; toast at 90 % usage and 5 min before cache goes coldsessions/<id>.quota.json every 60 s, marked ended on exit
task-progressRegisters a progress tool so Claude reports multi-step work; draws a progress bar above the input boxsessions/<id>.progress.json on every change
version-boardSide pane listing preview servers from headless claude -p runs, with effort level and done/running; blocks opening more than 3 at onceversions.json every 8 s
todo-pane/daiban side pane showing "in progress" and "published, wrapping up" from a Markdown logtodo.json every 5 min and after each turn
publish-pulseStatus line with days since last publish (traffic light) and the next scheduled releasepulse.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:

  • Keep the mod tiny and the file format boring. One JSON object per file, with an at timestamp; the app treats a session as closed after 150 s without an update, so crashes clean themselves up.
  • Write, don't serve. Writing files beats running a local server: nothing to keep alive, nothing to secure, and any app (SwiftUI, a menu-bar script, a web page) can read it.
  • Pure logic in its own file. quota.ts, bar.ts, scan.ts, parse.ts, pulse.ts have no engine calls, so they're unit-tested without Claude Code running.
  • Command names must be ASCII letters, digits, _ 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.

Data format

Everything lives in ~/.claude/notch/:

FileShape
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.

Developing the UI

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

FAQ

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.

For AI coding assistants

llms.txt lists every file worth reading in this repo, with one line each.

About

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

License

MIT

Source 2 files
hooks/register.ts 74 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { formatPulse, localNow, lastPublished, nextScheduled } from './pulse'
4import type { Doc } from './pulse'
5
6// ── 可配置项 ──
7// 环境变量 NOTCH_PUBLISH_ROOT:发布记录根目录的绝对路径,下面要有「发布中/<项目>/…入口.md」和「已发布/*.md」;没设就不写 pulse.json
8const publishRoot = ($: EngineInterface) => $.env.get('NOTCH_PUBLISH_ROOT')
9// 环境变量 NOTCH_TZ_OFFSET_HOURS:你所在时区相对 UTC 的小时数,默认 8(北京时间)
10const tzOffset = async ($: EngineInterface) => {
11  const raw = await $.env.get('NOTCH_TZ_OFFSET_HOURS')
12  const n = Number(raw)
13  return raw && Number.isFinite(n) ? n : 8
14}
15// 刘海台读这里:~/.claude/notch/pulse.json
16const feedPath = async ($: EngineInterface) => `${(await $.env.get('HOME')) ?? ''}/.claude/notch/pulse.json`
17// 多久刷新一次
18const REFRESH_MS = 10 * 60 * 1000
19
20const readDocs = async ($: EngineInterface, dir: string, pick: (name: string) => boolean) => {
21  const entries = await $.fs.list(dir).catch(() => [])
22  const docs: Doc[] = []
23
24  for (const entry of entries) {
25    if (entry.kind === 'file' && entry.name.endsWith('.md') && pick(entry.name)) {
26      const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => '')
27      docs.push({ title: entry.name.replace(/\.md$/, '').slice(0, 12), text })
28    }
29  }
30
31  return docs
32}
33
34// 发布中/<项目>/<项目>·入口.md,标题用文件夹名
35const readEntries = async ($: EngineInterface, root: string) => {
36  const folders = await $.fs.list(`${root}/发布中`).catch(() => [])
37  const docs: Doc[] = []
38
39  for (const folder of folders) {
40    if (folder.kind !== 'dir') continue
41    const inside = await readDocs($, `${root}/发布中/${folder.name}`, name => name.includes('入口'))
42    docs.push(...inside.map(doc => ({ ...doc, title: folder.name })))
43  }
44
45  return docs
46}
47
48const refresh = async ($: EngineInterface) => {
49  const root = await publishRoot($)
50  if (!root) return
51  const now = localNow(await $.clock.now(), await tzOffset($))
52  const entries = await readEntries($, root)
53  const published = await readDocs($, `${root}/已发布`, () => true)
54  const text = formatPulse(lastPublished([...entries, ...published]), nextScheduled(entries, now), now)
55  $.ui.status(text)
56  void $.fs.write(await feedPath($), JSON.stringify({ at: await $.clock.now(), text })).catch(() => undefined)
57}
58
59export const register: Register = on => {
60  on('session.start', async ($, e, next) => {
61    void refresh($).catch(() => $.ui.status('⚪ 发片节奏读取失败'))
62    $.clock.every(REFRESH_MS, () => refresh($).catch(() => undefined))
63
64    return next(e)
65  })
66
67  // 每轮结束顺手刷新一次,刚改完发布记录也能马上看到
68  on('turn.complete', async ($, e, next) => {
69    void refresh($).catch(() => undefined)
70
71    return next(e)
72  })
73}
74
hooks/pulse.ts 102 lines
1export type Doc = { title: string; text: string }
2export type Last = { date: string; title: string }
3export type Next = { when: string; title: string; what: string }
4
5// 「2026-10-08 14:59 已发」「已发布(2026-07-13)」两种写法都算发过
6const DONE_AFTER = /(\d{4}-\d{2}-\d{2})[^;;\n]{0,12}已发/g
7const DONE_BEFORE = /已发布[((](\d{4}-\d{2}-\d{2})/g
8const WHEN = /(\d{4}-\d{2}-\d{2})(?:\s+(\d{1,2}:\d{2}))?/
9
10const HEAD_LINES = 60
11
12// 网址里常带日期(幕后页链接之类),找日期前先去掉
13const URL = /<?https?:\/\/[^\s>)]+>?/g
14
15const headOf = (text: string) => text.split('\n').slice(0, HEAD_LINES).join('\n').replace(URL, '')
16
17// 入口文件只看「## 进度」那一段;没有这一段就看开头
18const progressOf = (text: string) => {
19  const lines = text.split('\n')
20  const start = lines.findIndex(line => line.trim().startsWith('## 进度'))
21  if (start < 0) return headOf(text)
22
23  const rest = lines.slice(start + 1)
24  const end = rest.findIndex(line => line.trim().startsWith('## '))
25
26  return (end < 0 ? rest : rest.slice(0, end)).join('\n').replace(URL, '')
27}
28
29export const lastPublished = (docs: readonly Doc[]): Last | undefined => {
30  let last: Last | undefined
31
32  for (const doc of docs) {
33    const head = headOf(doc.text)
34    const dates = [...head.matchAll(DONE_AFTER), ...head.matchAll(DONE_BEFORE)].map(m => m[1] ?? '')
35
36    for (const date of dates) {
37      if (date !== '' && (last === undefined || date > last.date)) {
38        last = { date, title: doc.title }
39      }
40    }
41  }
42
43  return last
44}
45
46// 发布中 入口文件里还没「已发」、时间在 now 之后的那几行,取最早的一条
47export const nextScheduled = (entries: readonly Doc[], now: string): Next | undefined => {
48  let next: Next | undefined
49
50  for (const entry of entries) {
51    for (const raw of progressOf(entry.text).split('\n')) {
52      const line = raw.trim()
53      if (!line.startsWith('- ') || line.includes('已发')) continue
54
55      const m = WHEN.exec(line)
56      if (!m) continue
57
58      const when = `${m[1]} ${m[2] ?? '23:59'}`.replace(/ (\d):/, ' 0$1:')
59      if (when < now) continue
60
61      if (next === undefined || when < next.when) {
62        const what = line.slice(2).split(/[::]/)[0]?.trim() ?? ''
63        next = { when, title: entry.title, what }
64      }
65    }
66  }
67
68  return next
69}
70
71const daysBetween = (from: string, to: string) =>
72  Math.round((Date.parse(to.slice(0, 10)) - Date.parse(from.slice(0, 10))) / 86_400_000)
73
74// 「YouTube / B站 / 视频号」这种写成「3 平台」,其他原样
75const shortWhat = (what: string) => {
76  const parts = what.split('/').map(p => p.trim()).filter(Boolean)
77
78  return parts.length > 1 ? `${parts.length} 平台` : what
79}
80
81export const formatPulse = (last: Last | undefined, next: Next | undefined, now: string): string => {
82  const parts: string[] = []
83
84  if (last) {
85    const days = daysBetween(last.date, now)
86    const light = days <= 3 ? '🟢' : days <= 7 ? '🟡' : '🔴'
87    parts.push(`${light} 距上次发片 ${days} 天(${last.title})`)
88  } else {
89    parts.push('⚪ 没找到发片记录')
90  }
91
92  if (next) {
93    parts.push(`下一发 ${next.when.slice(5)} ${next.title} ${shortWhat(next.what)}`)
94  }
95
96  return parts.join(' · ')
97}
98
99// 插件环境的时区不一定是本地,按固定的 UTC 偏移算(小时,默认 8 = 北京时间)
100export const localNow = (ms: number, offsetHours = 8) =>
101  new Date(ms + offsetHours * 3_600_000).toISOString().slice(0, 16).replace('T', ' ')
102