Do not disturb for the kit's panes: profiles that hide the history, the dev activity, the reminders or the handoffs, or keep only some repos and groups; turned…

Skills, mods, hooks and a status line for Claude Code, plus a sync engine that keeps your Claude Code config the same on all your computers. Every part is tested on macOS, Linux and Windows on each change.
Every part is a plugin of this repo's marketplace. Add it once, then install the parts you want, from Claude Code:
/plugin marketplace add robinalexandre/claude-code-kit
# Every day, for anyone
/plugin install team@claude-code-kit
/plugin install statusline@claude-code-kit
/plugin install file-tools-guard@claude-code-kit
/plugin install recent-prompts@claude-code-kit
/plugin install dnd@claude-code-kit
/plugin install paste-preview@claude-code-kit
/plugin install recent-activity@claude-code-kit
/plugin install session-registry@claude-code-kit
/plugin install drift-check@claude-code-kit
/plugin install drift-check-hook@claude-code-kit
/plugin install nocturne-notify@claude-code-kit
# Development work
/plugin install git-identity-guard@claude-code-kit
/plugin install pr-watch@claude-code-kit
/plugin install pr-pane@claude-code-kit
/plugin install test-evidence@claude-code-kit
/plugin install ci-review@claude-code-kit
/plugin install capability-builder@claude-code-kit
/plugin install learn@claude-code-kit
/plugin install kb@claude-code-kit
# Several computers
/plugin install config-sync@claude-code-kit
/plugin install handoff@claude-code-kit
/plugin install daily-sync@claude-code-kit
/plugin install reminders@claude-code-kit
/plugin install reminders-handoffs@claude-code-kit
# Not dev
/plugin install video-montage@claude-code-kit
/plugin install insta360-montage@claude-code-kit
From a terminal it's the same with claude plugin marketplace add … and claude plugin install …; or /plugin alone to browse the parts. What a part needs comes with it: pr-pane brings pr-watch; reminders brings daily-sync, which brings config-sync. Start a new session afterwards.
Keep them up to date: Claude Code doesn't auto-update a marketplace other than Anthropic's own unless you turn it on, once: /plugin → Marketplaces → claude-code-kit → Enable auto-update. It then updates the parts in the background after a session starts (and asks for a restart). Or set it in ~/.claude/settings.json:
"extraKnownMarketplaces": {
"claude-code-kit": { "source": { "source": "github", "repo": "robinalexandre/claude-code-kit" }, "autoUpdate": true }
}
By hand: claude plugin marketplace update claude-code-kit, then claude plugin update <part>@claude-code-kit.
Setup: a part that needs a few answers (the status line in your settings, the emails for the email guard, where pr-watch looks, the phone for reminders, your private repo for config-sync, the zsh line for session-registry) says so at the first session after install, and Claude gives you the command to run in your terminal; it stops asking once it's done or you say no. Options: the language of the team rules and the opening text of drift-check are asked when you install from /plugin, and changed with claude plugin configure <part>@claude-code-kit.
The installer copies the parts you pick into ~/.claude instead. Use it to pick from a checklist, to install without a marketplace, or to edit your copies. A part already installed as a plugin is skipped.
Keep them up to date: the installer also adds a small session-start check (kit-update-check). Once a day, in the background, it fetches the clone you installed from. When that clone has changes for the parts you installed, or new parts you could add, Claude tells you at the next session start what changed and offers to update. ./install.sh --update pulls the clone and installs the changed parts again, without asking their setup questions again; ./install.sh --update <new part>… adds new ones too. Each new part is mentioned once. Turn the check off with bash ~/.claude/hooks/kit-update-check.sh off (on to resume). What came from where is recorded in ~/.claude/kit-install.json.
git clone https://github.com/robinalexandre/claude-code-kit.git
cd claude-code-kit
./install.sh # a checklist: arrows, Enter to pick, → for what each part does, then Install
./install.sh --plain # the same as one question per part
./install.sh --list # lists the parts
./install.sh --all # installs every part offered on this computer
./install.sh statusline file-tools-guard # installs the named parts
./install.sh --update # pulls, then installs again the parts that changed
On Windows, from Git Bash the same, or from PowerShell: powershell -ExecutionPolicy Bypass -File .\install.ps1 (it installs git and jq with winget when missing, then runs install.sh in Git Bash).
The installer shows the parts as a checklist by group (every day, development, several computers, not dev): ↑↓ to move, Enter or Space to pick a part or a whole group, → or Tab to unfold why, what, when and who for a part, a for all, then Install at the bottom. It greys out what doesn't apply to this computer, adds what a picked part needs (pr-pane brings pr-watch, reminders brings daily-sync), then sets up each part it installed: the emails for the email guard, where pr-watch looks for your PRs, the phone for reminders, the browser tool for test-evidence. Run a part's setup again any time with bash ~/.claude/<part>/setup.sh (hooks: bash ~/.claude/hooks/setup/<hook>.sh). It copies each part into ~/.claude, adds its hooks to ~/.claude/settings.json and saves whatever it replaces in ~/.claude/backups/before-kit-<date>/. Running it again is safe. Start a new Claude Code session afterwards. Needs jq.
To try it first without touching your own setup, tests/install-sandbox.sh runs the installer in a throwaway home folder, shows what it left and checks your files are unchanged; --claude <sandbox> then opens a first Claude Code session there.
The sync engine is a part too (./install.sh config-sync), added on its own when you pick handoff, daily-sync or reminders: the installer asks for the clone URL of an empty private git repo of your own (any host), clones it to ~/.claude-sync and sets it up; on your next computer, give the same URL. By hand: sync/README.md.
A new computer like another one: on a computer whose config isn't synced yet, the installer first asks whether it is synced from another computer, and if so sets up the sync before anything else. Each computer records its answers to the kit's questions in ~/.claude/kit-answers.txt (never a secret, such as the ntfy topic), and the sync shares them with the parts it installed. So the new computer can be set up:
~/.zshrc line, the status line it has.A part's setup run later (a plugin's, at its first session) uses the same choice, kept in ~/.claude/kit-reuse.txt; delete that file to be asked again.
rules/team.md is the base of everything here: Claude and you work as one team that builds, thinks things through and challenges each other. It asks before touching production or shared infrastructure (in two lines, not twenty), before each commit, push and PR, and before decisions that need context only you have, with the options and a recommendation. It proposes a skill, mod or hook when you keep asking for the same thing, and an AI review step with proof (screenshots, videos) on the CI of repos that don't have one. The team plugin hands them to Claude at each session start, in the language you pick in its options, so they update with the plugin; install.sh asks for the language and adds them to ~/.claude/CLAUDE.md once instead. A capability can use another language when you say so.
| Skill | What it does | Needs |
|---|---|---|
| test-evidence | Captures proof that a ticket works locally: browser screenshots, a short video of the flow, the network requests and console behind it (through the chrome-devtools MCP), or one app window or screen region, never the whole screen unless asked. Masks tokens and cookies, writes a short test report and, after your yes, attaches it to the Jira issue or as a resolved comment on the Azure DevOps PR; on GitHub, it gives you the comment and the files to drop in. | chrome-devtools MCP, jq, perl; video needs ffmpeg |
| ci-review | Proposes, once per repo, an AI review step on the CI and sets it up with you: an agent reads each PR/MR's diff and its proof (what test-evidence captures) and posts a verdict (works / doesn't / can't tell, with what to capture). Templates for anthropics/claude-code-action, the CLI in headless mode (claude -p) on GitHub Actions and GitLab CI, and cloud providers; the review prompt is a plain file any agent CLI can take. | an API key or a cloud provider account, set as a CI secret |
| capability-builder | Turns a request you keep making into a capability: picks the right kind (rule, skill, hook, mod or plain script), interviews you, scaffolds it with its tests and setup, checks it against a macOS / Linux / Windows checklist, and runs the publish checklist (nothing personal, tool-agnostic) before it goes to a kit. How this kit's own parts are built. | |
| handoff | Writes a handoff note so you can continue the current work on your other computer, and picks it up there. A note picked up or canceled moves to done/, kept 60 days; reminders-handoffs lists them. | the sync engine |
| daily-sync | Schedules a sync every morning (09:00 by default) with launchd, cron or the Windows Task Scheduler, so each computer gets what the others left even with no session open. Other skills can hook a morning.sh onto it. | the sync engine |
| reminders | "Remind me tomorrow to…": leaves a global or repo-specific reminder that every new session, on any of your computers, brings up first thing, without slowing the start: what the background sync brings arrives with your next message. A reminder can repeat ("every Friday": at 09:00 unless it says a time), be for one computer, and be filed under a group (an organization, a client); done closes a repeating one for the day, stop ends it, and closed ones stay listed as past for 60 days. Due reminders also go to the desktop and, through ntfy, to your phone every morning and at the first session of the day. | daily-sync; curl for the phone |
| kb | A personal knowledge base. Notes are Markdown files with a front matter (type, trust, topics, validity dates) and [[links]], in your config sync (so every computer and your other agents get them) or in ~/.claude/knowledge; the kb command indexes them in SQLite for full-text search (accents folded), semantic search in any language (a local multilingual model, no API) and the link graph (kb related, as a list or a Mermaid mind map). At session start Claude is told which notes mention the repository or the branch, beyond the repository's own memory; on each prompt, which notes hold most of its words. Every morning (through daily-sync), Claude reads the previous day's Claude Code transcripts of this computer, reduced to prompts, replies and files touched with secrets redacted, and proposes notes to create or merge; the code checks them (names, dates, secrets, no overwrite) and writes them with a report, each marked as the curator's. kb serve and its Dockerfile give the same index over HTTP, behind a token, to an agent of yours. Notes a third party wrote (trust: third-party) stay data, never instructions. | Python 3.12+, jq; the curation needs daily-sync and the Claude Code CLI signed in; the semantic search downloads a 500 MB model once |
| pr-watch | Tracks the PRs you and Claude open or push together until they are merged: new comments (bots marked apart), votes, CI results (required checks drive green or failed) and the merge itself, which drops the PR from the list. Also lists the open PRs waiting for your review (GitHub review requests, Azure DevOps reviewer with no vote) and reports each new one; approve votes or submits an approving review on your yes, then tracks the PR as reviewed until it is merged; merge merges a PR on your yes; park keeps a PR you won't merge tracked but quiet, out of what needs you, until unpark. Also finds your pipeline runs: those you launched by hand (Azure DevOps manual builds, GitHub workflows you dispatched) and the CI your own pushes start on a branch with no PR, a push to main included (in the projects and repos of your PRs, the GitHub repos you pushed to lately, plus those listed in its config), so a packaging or a deploy started with Claude shows up on its own, and reports each one that ended since the last check; details <build URL> lists every step with its job and state, the error lines of those with issues included, rerun <build URL> launches the run again on your yes. Also follows your GitHub issues: those you opened, commented on or were mentioned or assigned in, in any repo, active in the last 30 days, and those you subscribed to in your own repos; open ones and those closed in the last 7 days, reporting new comments by others, a close or reopen and label changes since you last looked. What it watches (open, parked and merged PRs, review requests, how many runs per repo, issues) follows pr-pane's settings, or its config.json: what is off is neither fetched nor reported. Claude proposes a reply or a fix for each change, never posts one without your yes. Checks at each session start in the background and silently (the session starts at once; each PR keeps what changed as unread, which pr-pane shows until you unfold that PR), hourly in a session (a durable scheduled task) and every morning through daily-sync, with a desktop or phone notification. The list lives in the sync repo, so it follows you on every computer and never touches the project repos. With the sync repo on GitHub (recommended), what one computer fetches is shared with the others on a branch of it that never grows, read with one conditional request: each host is asked by one computer only, again once its share is older than 2 minutes while a CI runs or 10 otherwise (asked at setup), the pane opens at once, and a host one computer cannot reach (a company's Azure DevOps from home) is still shown from another. Azure DevOps (az login or a PAT) and GitHub (a token, gh, or anonymous for public repos). | Python 3.9+; the sync engine to share the list across computers |
Mods change Claude Code's own interface. They install as plugins like the other parts; install.sh loads its copies through CLAUDE_CODE_PLUGIN_DIRS. Run a mod's tests with claude plugin test mods/<name>.
| Mod | What it does |
|---|---|
| paste-preview | Shows pasted text in full in the prompt box and the transcript, and previews pasted images above the prompt and under the sent message (macOS, Windows). On Windows, paste an image with Alt+V: in some terminals (Ghostty) Ctrl+V pastes text only. |
| pr-pane | A Dev activity button at the top right of the band above the prompt, whose number counts only what needs you (a PR or a run in red, not acknowledged), colored by what you have to do (red and marked !: something needs you; yellow: · k to review or · k running; green: a PR is ready to merge; dim: everything waits for others) and /prs open a pane of the PRs pr-watch tracks, by priority. At the top, a collapsible "Pipeline runs" section lists your runs (a packaging or a deploy launched by hand, the CI of your pushes outside a PR, marked push to <branch>), one block per repo: the running ones first, then those ended in the last day, each with its pipeline, branch, status, time and duration, open by default while one runs; a run whose host did not answer keeps its last known state, marked cached. A run unfolds from its line: rerun (press twice: a new run of the same pipeline, branch and parameters on Azure DevOps, a re-run on GitHub), diagnose with Claude when it did not succeed (asks the session to read the steps and logs and propose the fix), then every step of the run grouped by job in pipeline order, each job folded unless a step had a problem, each step's messages and log lines under their own toggle. Then, under a red title and one open block per repo, the PRs that need you: a failed or expired CI, a merge conflict, changes requested, or a new comment since the last check (unfolding the PR marks its changes read: the button clears, the PR stays where it is until the session ends). Then the PRs waiting for your review, per repo too, each with review with Claude (a quick review by a subagent, nothing posted) and approve (press twice; it reads approving… while it runs). Then a collapsible "Waiting for others" section with your other open PRs, one collapsible block per repo in merge order (a stacked PR under the one it targets). Then "Issues I follow", per repo with unfollow all (press twice): each issue on one line (yellow when it moved since you last looked, with · n issues updated on the band), unfolding to who opened it, its labels and comments and what changed, which marks it read, with unfollow. A PR you park (its park button, under its details) leaves the red list and the band for a folded "Parked, kept but quiet" section, with unpark. Last, before the recent list, a collapsible "Approved, waiting for the merge" section holds the PRs you approved and your own PRs that only wait for the merge, grouped per repo the same way, each with a merge button (press twice; merging… while it runs; squash on GitHub when the repo allows it, complete on Azure DevOps). Every section and repo block wears the worst color of what it holds (red, then yellow, then green), so a folded one still says where to look. A red PR or run can be acknowledged (ack): it turns blue, leaves the count and the colors above it, and comes back when it changes; ack + remind asks Claude for a reminder and acks until its date, shown beside it. Every PR is one line, a colored dot (red: you have to act, yellow: someone or something else, green: only the merge is left), its number, title and what it waits for; its details fold under it: status, CI, votes, conflicts, the PR's description and the changes since the last check. A PR whose CI runs, and the repo block and section around it, open by default so a CI you just launched is on screen (a folded block hiding one says CI running); what you fold stays folded. Each PR's CI checks fold under it, open while they run or when a required one failed, refreshed every minute until they end; a failed check has details (its failed steps and error lines) and rerun (press twice: re-queue on Azure DevOps, rerun failed jobs on GitHub). A merge conflict is shown in red with a fix with Claude button that asks the session to rebase and resolve it (it asks you before pushing). PRs merged or closed in the last 7 days stay listed at the bottom. An error shared by several PRs and lists (a host this computer cannot reach or sign in to) is shown once, with what it hit. Click a PR to open it in the browser; refresh (or r) fetches them live and lists what changed since the last check, without marking it seen. Its settings, in the Configuration pane or claude plugin configure, turn each section on or off (open PRs, parked, merged and closed, review requests, issues), set how many pipeline runs to show per repo (0 hides them), how long an issue stays followed without moving, how often each part is refreshed by itself, and follow the kit's low power mode (see configuration); pr-watch follows them too, so what is off is no longer fetched or reported. Needs pr-watch. |
| reminders-handoffs | One pane for whichever of reminders and handoff you have, a tab each when you have both. A button at the right of the band above the prompt, named after them (Reminders 1 now · Handoffs 1, yellow when a reminder is due or a note from your other computer waits), /reminders-pane and /handoffs-pane open it. Reminders come in three foldable sections, Now, Upcoming (with when each shows next, repeating ones included) and Past; handoff notes in Open and Done. Several groups in a section (a reminder's group, else its folder, else its computer; a note's repo) each get a foldable title. Each item is one line that unfolds to its dates, repo, branch and text, with pick (hands the reminder or the note to this session now), done (done today on a repeating reminder), stop, cancel and delete (press twice), and restore on a past one (back as it was, dates and repeat days included). |
| recent-prompts | Shows your last two messages above the prompt, and a searchable history pane; click one to scroll the transcript to it. With the learn mod, the history also lists each 💡 lesson between the messages it answered. |
| dnd | Do not disturb for the recent-prompts, pr-pane and reminders-handoffs panes. A profile hides a pane (its band button too), or keeps only some repos, reminder groups or handoff repos, @here being the session's own repo; counts and colors follow what is kept. Turn one on by hand with /dnd work 1h or by asking Claude, for this session, this computer or all computers (asked when you don't say; the status line shows 🔕 work · session while one is on), or let a rule turn it on: a Wi-Fi network, days and hours (days=weekend, from=19:00 to=08:00), a folder (a repo shows only its own). /dnd, or its button in the Configuration pane, opens the Do not disturb pane: a press per profile asks until when and where, then Activate turns it on; a name creates one, and each profile unfolds to what it keeps (a checklist) and the rules that turn it on; dnd.sh edit <profile> is the same checklist on a terminal, with the installer's keys. Profiles live in the sync repo when there is one, so every computer gets them. Offered only beside one of those panes. |
| configuration | A Configuration button at the right of the band and /configuration open the Configuration pane: every mod's settings, grouped per mod (a toggle, a choice, or a number or text typed and saved with Enter), changed for all computers or, with a private sync, for this computer only; a setting kept for this computer says the value the other computers use. Its own setting is the kit's low power mode: on, off, or on battery (the default: while the computer runs on its battery or in its low power mode, read on macOS, Linux and Windows), everything that fetches by itself (the dev activity pane and pr-watch, the reminders and handoffs pane, do not disturb) does it five times less often, and
hooks/register.tsx 660 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DndActive, DndData, DndPart, DndPartRule, DndProfile, DndRule } from '../types'
5
6// The Do not disturb pane. The configuration mod's pane (every mod's settings) opens it, and it opens
7// that one: Claude Code shows the two as tabs.
8const PANE = 'dnd'
9const TITLE = 'Do not disturb'
10const COMMAND = 'dnd'
11const CONFIGURATION_PANE = 'configuration'
12const TICK_MS = 60_000
13// Under the pointer, what folds turns to the theme's suggestion color with the text drawn on it in
14// the theme's inverse text color: readable in a light and a dark theme alike.
15const LIT_BOX = { backgroundColor: 'suggestion' }
16const LIT_TEXT = { color: 'inverseText', dimColor: false }
17
18// The panes read it under the same shape; see the types.
19const active = atom({ plugin: 'dnd', key: 'active' } as const, null, { shape: 'dnd-active/1' })
20const data = atom({ plugin: 'dnd', key: 'data' } as const, null)
21const problem = atom({ plugin: 'dnd', key: 'problem' } as const, '')
22const pending = atom({ plugin: 'dnd', key: 'pending' } as const, '')
23const unfolded = atom({ plugin: 'dnd', key: 'unfolded' } as const, {})
24const armed = atom({ plugin: 'dnd', key: 'armed' } as const, '')
25const busy = atom({ plugin: 'dnd', key: 'busy' } as const, false)
26
27const PARTS: { part: DndPart; title: string }[] = [
28 { part: 'history', title: 'History' },
29 { part: 'prs', title: 'Dev activity' },
30 { part: 'reminders', title: 'Reminders' },
31 { part: 'handoffs', title: 'Handoffs' },
32]
33
34const HERE = '@here'
35const LENGTHS = ['30m', '1h', '2h']
36type Scope = 'session' | 'computer' | 'everywhere'
37const SCOPES: Record<Scope, string[]> = {
38 session: ['here', 'session', 'this'],
39 computer: ['computer', 'pc', 'mac'],
40 everywhere: ['all', 'everywhere'],
41}
42const WHERE: [Scope, string][] = [
43 ['session', 'this session'],
44 ['computer', 'this computer'],
45 ['everywhere', 'all computers'],
46]
47const scopeOf = (word: string) => (Object.keys(SCOPES) as Scope[]).find(scope => SCOPES[scope].includes(word))
48
49const slashed = (path: string) => path.replace(/\\/g, '/')
50
51const home = async ($: EngineInterface) => slashed((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '~')
52
53const truncate = (text: string, width: number) =>
54 text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
55
56const runs = async ($: EngineInterface, argv: string[]) => {
57 try {
58 return (await $.process.run(argv, { timeoutMs: 10_000 })).exitCode === 0
59 } catch {
60 return false
61 }
62}
63
64// On Windows, Claude Code's own PATH has Git's cmd folder (git) but not bash: Git Bash is found
65// from the variable Claude Code reads for it, then from git's own install folder.
66let bashFound: string | undefined
67
68const bash = async ($: EngineInterface) => {
69 if (bashFound !== undefined) {
70 return bashFound
71 }
72
73 const fromGit = async () => {
74 const execPath = await $.process.run(['git', '--exec-path'], { timeoutMs: 10_000 }).catch(() => undefined)
75 const root = slashed(execPath?.stdout.trim() ?? '').replace(/\/(mingw64|clangarm64|mingw32)\/.*$/, '')
76
77 return execPath?.exitCode === 0 && root !== '' ? `${root}/bin/bash.exe` : ''
78 }
79 const candidates = [
80 async () => slashed((await $.env.get('CLAUDE_CODE_GIT_BASH_PATH')) ?? ''),
81 async () => 'bash',
82 fromGit,
83 async () => `${slashed((await $.env.get('ProgramFiles')) ?? 'C:/Program Files')}/Git/bin/bash.exe`,
84 ]
85
86 for (const candidate of candidates) {
87 const path = await candidate()
88
89 if (path !== '' && (await runs($, [path, '-c', 'exit 0']))) {
90 bashFound = path
91
92 return path
93 }
94 }
95
96 throw new Error('Git Bash not found: set CLAUDE_CODE_GIT_BASH_PATH to bash.exe')
97}
98
99// The script ships in this plugin's own skill, beside the manifest's folder.
100const script = ($: EngineInterface) => `${slashed($.plugin.root).replace(/\/\.claude-plugin\/?$/, '')}/skills/dnd/dnd.sh`
101
102const run = async ($: EngineInterface, args: string[]) => {
103 const ran = await $.process.run([await bash($), script($), ...args], { timeoutMs: 30_000 })
104
105 return { ...ran, said: (ran.exitCode === 0 ? ran.stdout : ran.stderr).trim().split('\n')[0] ?? '' }
106}
107
108const forSession = async ($: EngineInterface) => ['--session', await $.session.id()]
109
110const SHOWN_KEPT = 50
111
112// The status line shows the profile on beside the others: it reads it from this file, by session id.
113const show = async ($: EngineInterface, now: DndActive) => {
114 const base = await home($)
115
116 if (base === '~') {
117 return
118 }
119
120 const path = `${base}/.claude/.dnd/active.json`
121 const id = await $.session.id()
122 let shown: Record<string, unknown> = {}
123
124 try {
125 const parsed: unknown = JSON.parse(await $.fs.read(path))
126 shown = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
127 } catch {
128 shown = {}
129 }
130
131 const kept = Object.entries(shown).filter(([session]) => session !== id).slice(-(SHOWN_KEPT - 1))
132 await $.fs.write(path, `${JSON.stringify(Object.fromEntries([...kept, [id, { profile: now.profile, scope: now.scope, untilText: now.untilText }]]), null, 1)}\n`)
133}
134
135const setActive = async ($: EngineInterface, now: DndActive) => {
136 await update($, active, () => now)
137 await show($, now).catch(() => undefined)
138}
139
140// What is on for this session: every minute, after each turn, and after each change.
141const refresh = async ($: EngineInterface) => {
142 try {
143 const ran = await run($, ['active', slashed(await $.session.cwd()), ...(await forSession($))])
144
145 if (ran.exitCode !== 0) {
146 await update($, problem, () => ran.said || `dnd.sh exited with ${ran.exitCode}`)
147
148 return
149 }
150
151 const now = JSON.parse(ran.stdout) as DndActive
152 const before = await read($, active)
153
154 // The other panes redraw on each write: only a change is written.
155 if (JSON.stringify(before) !== JSON.stringify(now)) {
156 await setActive($, now)
157 }
158
159 await update($, problem, () => '')
160 } catch (error) {
161 await update($, problem, () => String(error).split('\n')[0] ?? '')
162 }
163}
164
165// Profile edits made from the pane, written one after another; `edits` counts every one queued, so
166// a read started before the last of them knows it is stale.
167let edits = 0
168let writes: Promise<unknown> = Promise.resolve()
169
170// Profiles, rules and the names each part lists, for the pane.
171const load = async ($: EngineInterface) => {
172 const since = edits
173
174 try {
175 const ran = await run($, ['json', slashed(await $.session.cwd()), ...(await forSession($))])
176
177 if (ran.exitCode !== 0) {
178 await update($, problem, () => ran.said)
179
180 return
181 }
182
183 if (since !== edits) {
184 return
185 }
186
187 const loaded = JSON.parse(ran.stdout) as DndData
188 await update($, data, () => loaded)
189
190 if (JSON.stringify(await read($, active)) !== JSON.stringify(loaded.active)) {
191 await setActive($, loaded.active)
192 }
193
194 await update($, problem, () => '')
195 } catch (error) {
196 await update($, problem, () => String(error).split('\n')[0] ?? '')
197 }
198}
199
200// Runs one change, says what went wrong, and reads everything again.
201const change = async ($: EngineInterface, args: string[], quiet = false) => {
202 await update($, busy, () => true)
203
204 try {
205 const ran = await run($, args)
206
207 if (ran.exitCode !== 0 || !quiet) {
208 $.ui.toast(ran.exitCode === 0 ? ran.said : `DnD: ${ran.said}`)
209 }
210
211 return ran
212 } finally {
213 await load($)
214 await update($, busy, () => false)
215 }
216}
217
218// One part of a profile set from the pane: shown at once, written behind, everything read again
219// once the last press is written. Reading it all takes seconds; a press must not wait for it.
220const editPart = ($: EngineInterface, profile: string, part: DndPart, rule: DndPartRule) => {
221 const mine = ++edits
222
223 void update($, data, all => (all === null ? all : { ...all, profiles: { ...all.profiles, [profile]: { ...all.profiles[profile], [part]: rule } } }))
224 writes = writes.then(async () => {
225 const ran = await run($, ['set', profile, part, rule.mode ?? 'show', ...(rule.mode === 'only' ? rule.only ?? [] : [])]).catch(error => ({
226 exitCode: 1,
227 said: String(error).split('\n')[0] ?? '',
228 }))
229
230 if (ran.exitCode !== 0) {
231 $.ui.toast(`DnD: ${ran.said}`)
232 }
233
234 if (mine === edits) {
235 await load($)
236 }
237 })
238}
239
240const turnOn = async ($: EngineInterface, profile: string, length: string, scope: Scope) => {
241 await update($, pending, () => '')
242 const where = scope === 'session' ? await forSession($) : scope === 'everywhere' ? ['--everywhere'] : []
243 await change($, ['on', profile, ...(length !== '' ? [length] : []), ...where])
244}
245
246// A profile to turn on, asked until when and where in the pane: `<profile>|<for>|<scope>`.
247const ask = ($: EngineInterface, profile: string, length = '', scope: Scope = 'session') => update($, pending, () => `${profile}|${length}|${scope}`)
248
249// Every switch off: this session's, the computer's and every computer's; the rules decide again.
250const turnOff = async ($: EngineInterface) => {
251 await update($, pending, () => '')
252 await run($, ['off', ...(await forSession($))]).catch(() => undefined)
253 await change($, ['off'])
254}
255
256const openPane = async ($: EngineInterface) => {
257 await $.ui.open({ id: PANE, title: TITLE, columns: 64 })
258 await load($)
259}
260
261const isLength = (word: string) => /^(\d+[mhd]|\d{1,2}:\d{2})$/.test(word)
262
263// `/dnd`, `/dnd off`, `/dnd list`, `/dnd PROFILE [30m|2h|HH:MM] [here|pc|all]`: without where,
264// the pane asks.
265const command = async ($: EngineInterface, args: string) => {
266 const [first = '', ...rest] = args.trim().split(/\s+/).filter(word => word !== '')
267
268 if (first === '') {
269 await openPane($)
270
271 return 'Do not disturb pane opened.'
272 }
273
274 if (first === 'off') {
275 await turnOff($)
276
277 return (await read($, active))?.profile ? `Switch off; ${(await read($, active))?.profile} stays on by a rule.` : 'DnD off: everything shows.'
278 }
279
280 if (first === 'list') {
281 const ran = await run($, ['list', ...(await forSession($))])
282
283 return ran.exitCode === 0 ? ran.stdout.trimEnd() : ran.said
284 }
285
286 const length = rest.find(isLength) ?? ''
287 const scope = rest.map(scopeOf).find(one => one !== undefined)
288
289 if (scope === undefined) {
290 await ask($, first, length)
291 await openPane($)
292
293 return `Turn on ${first}${length !== '' ? ` for ${length}` : ''}: for this session, this computer or all computers? Choose in the Do not disturb pane.`
294 }
295
296 await turnOn($, first, length, scope)
297 const now = await read($, active)
298 const where = WHERE.find(([one]) => one === scope)?.[1] ?? scope
299
300 return now?.profile === first ? `DnD ${first} on for ${where}${now.untilText !== '' ? ` until ${now.untilText}` : ''}.` : (await read($, problem)) || `Could not turn ${first} on.`
301}
302
303// In words: what a profile hides or keeps.
304const summary = (profile: DndProfile) => {
305 const said = PARTS.flatMap(({ part, title }) => {
306 const rule = profile[part]
307
308 if (rule?.mode === 'hide') {
309 return [`no ${title.toLowerCase()}`]
310 }
311
312 if (rule?.mode === 'only') {
313 return [`${title.toLowerCase()}: ${(rule.only ?? []).map(name => (name === HERE ? 'this folder' : name)).join(', ') || 'none'}`]
314 }
315
316 return []
317 })
318
319 return said.length > 0 ? said.join(' · ') : 'shows everything'
320}
321
322const ruleText = (rule: DndRule) =>
323 [
324 rule.wifi ? `Wi-Fi ${rule.wifi}` : '',
325 rule.folder ? `in ${rule.folder}` : '',
326 rule.days ?? '',
327 rule.from || rule.to ? `${rule.from ?? '00:00'}-${rule.to ?? '24:00'}` : '',
328 ]
329 .filter(part => part !== '')
330 .join(', ')
331
332let ticking: (() => void) | undefined
333let ticks = 0
334
335// In the kit's low power mode (the configuration mod's, read under its shape), one tick in five reads.
336const LOW_POWER_FACTOR = 5
337const lowPower = atom({ plugin: 'configuration', key: 'power' } as const, null, { shape: 'low-power/1' })
338
339const tick = async ($: EngineInterface) => {
340 ticks += 1
341
342 if ((await read($, lowPower))?.isLow !== true || ticks % LOW_POWER_FACTOR === 0) {
343 await refresh($)
344 }
345}
346
347const start = async ($: EngineInterface) => {
348 await $.command
349 .register({ name: COMMAND, description: 'Do not disturb: open the profiles, or turn one on (/dnd work 1h), off (/dnd off)' })
350 .catch(() => undefined)
351 ticking?.()
352 ticking = $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
353 void refresh($).catch(() => undefined)
354}
355
356let refreshedAt = 0
357let loadAsked = false
358
359export const register: Register = on => {
360 on('session.start', async ($, e, next) => {
361 await start($)
362
363 return next(e)
364 })
365
366 // A /clear starts a fresh session without a session.start: the switch is read again once it has.
367 on('session.end', async ($, e, next) => {
368 const ended = await next(e)
369
370 if (e.reason === 'clear') {
371 $.clock.after(0, () => void start($))
372 }
373
374 return ended
375 })
376
377 // A turn can change the folder or follow a switch made in words: read it again, at most every 10s.
378 on('turn.complete', async ($, e, next) => {
379 const now = await $.clock.now()
380
381 if (now - refreshedAt >= 10_000) {
382 refreshedAt = now
383 void refresh($).catch(() => undefined)
384 }
385
386 return next(e)
387 })
388
389 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await command($, e.args) }))
390
391 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
392 const { Box, Button, Input, Text } = $.ui.resolve(e)
393 const shown = await read($, data)
394
395 // Opened from the Configuration pane's button, the profiles are read on the first draw.
396 if (shown === null && !loadAsked) {
397 loadAsked = true
398 void load($)
399 }
400
401 const now = (await read($, active)) ?? shown?.active ?? null
402 const trouble = await read($, problem)
403 const waiting = await read($, pending)
404 const open = await read($, unfolded)
405 const armedKey = await read($, armed)
406 const isBusy = await read($, busy)
407 const width = Math.max(30, e.props.bodyColumns - 2)
408
409 const toggle = (key: string) => void update($, unfolded, all => ({ ...all, [key]: !(all[key] ?? false) }))
410
411 // A second press confirms what cannot be undone.
412 const confirmButton = (key: string, label: string, onConfirm: () => void) =>
413 armedKey === key ? (
414 <Button key={`confirm-${key}`} label={`${label}? press again`} variant="primary" onPress={() => {
415 void update($, armed, () => '')
416 onConfirm()
417 }} />
418 ) : (
419 <Button key={`arm-${key}`} label={label} dimColor onPress={() => void update($, armed, () => key)} />
420 )
421
422 // A line that folds: the whole of it, lit under the pointer; `buttons` do something else.
423 const foldLine = (key: string, text: string, isOpen: boolean, buttons: unknown[] = [], room = 0) => (
424 <Box key={`line-${key}`} flexDirection="row" justifyContent="space-between">
425 <Box flexDirection="row" flexShrink={1} hover={LIT_BOX}>
426 <Button key={`fold-${key}`} label={`${isOpen ? '▾' : '▸'} ${truncate(text, Math.max(8, width - room - 4))}`} plain hover={LIT_TEXT} onPress={() => toggle(key)} />
427 </Box>
428 <Box flexDirection="row" flexShrink={0} gap={1}>
429 {buttons as never}
430 </Box>
431 </Box>
432 )
433
434 const status = () => {
435 if (trouble !== '') {
436 return `Could not read the profiles: ${truncate(trouble, width - 30)}`
437 }
438
439 if (now === null) {
440 return 'Loading…'
441 }
442
443 if (now.profile === '') {
444 return 'Off: everything shows.'
445 }
446
447 const end = now.untilText !== '' ? ` until ${now.untilText}` : ''
448
449 return now.profile === 'none' ? `Everything shows${end}, the rules quiet (${now.why}).` : `On: ${now.profile} (${now.why})${end}.`
450 }
451
452 // A profile to turn on: until when and where, picked, then one press. A length typed with
453 // /dnd that is not among the usual ones is offered too.
454 const pendingBlock = () => {
455 const [profile = '', length = '', scope = 'session'] = waiting.split('|')
456 const lengths = ['', ...LENGTHS, ...(length !== '' && !LENGTHS.includes(length) ? [length] : [])]
457 const choice = (key: string, label: string, isPicked: boolean, onPress: () => void) => (
458 <Button key={key} label={label} {...(isPicked ? { variant: 'primary' as const } : { plain: true as const, dimColor: true })} onPress={onPress} />
459 )
460
461 return (
462 <Box key="pending" flexDirection="column" marginTop={1}>
463 <Box flexDirection="row" justifyContent="space-between">
464 <Text bold>{`Turn on ${profile}:`}</Text>
465 <Button key="pending-cancel" label="cancel" dimColor onPress={() => void update($, pending, () => '')} />
466 </Box>
467 <Box flexDirection="row" gap={1} paddingLeft={2}>
468 <Text>Until</Text>
469 {lengths.map(one => choice(`until-${one || 'off'}`, one === '' ? 'off' : one, one === length, () => void ask($, profile, one, scope as Scope)))}
470 </Box>
471 <Box flexDirection="row" gap={1} paddingLeft={2}>
472 <Text>Where</Text>
473 {WHERE.map(([one, title]) => choice(`where-${one}`, title, one === scope, () => void ask($, profile, length, one)))}
474 </Box>
475 <Box paddingLeft={2} marginTop={1}>
476 <Button key="activate" label="Activate" variant="primary" onPress={() => void turnOn($, profile, length, scope as Scope)} />
477 </Box>
478 </Box>
479 )
480 }
481
482 // One part of a profile: a button per mode, the one on lit; `only` lists the names to check:
483 // the session's folder, the ones checked, what the part lists now, then every repo and folder.
484 const partBlock = (name: string, profile: DndProfile, part: DndPart, title: string) => {
485 const rule = profile[part]
486 const mode = rule?.mode ?? 'show'
487 const kept = rule?.only ?? []
488 const lastPart = (one: string) => one.toLowerCase().replace(/^.*\//, '')
489 const names = [HERE, ...kept, ...(shown?.known[part] ?? []), ...(shown?.workspaces ?? [])].filter(
490 (one, index, all) => all.findIndex(other => lastPart(other) === lastPart(one)) === index
491 )
492 const setRule = (nextRule: DndPartRule) => editPart($, name, part, nextRule)
493 const modes: ['show' | 'hide' | 'only', string][] = part === 'history' ? [['show', 'show'], ['hide', 'hide']] : [['show', 'all'], ['hide', 'hide'], ['only', 'only…']]
494
495 return (
496 <Box key={`part-${name}-${part}`} flexDirection="column" paddingLeft={4}>
497 <Box flexDirection="row" gap={1}>
498 <Text>{`${title}:`}</Text>
499 {modes.map(([one, label]) => (
500 <Button
501 key={`mode-${name}-${part}-${one}`}
502 label={label}
503 {...(one === mode ? { variant: 'primary' as const } : { plain: true as const, dimColor: true })}
504 onPress={() => one !== mode && setRule({ mode: one, only: kept })}
505 />
506 ))}
507 </Box>
508 {mode === 'only' &&
509 names.map(one => {
510 const isKept = kept.some(other => other.toLowerCase() === one.toLowerCase())
511
512 return (
513 <Box key={`name-${name}-${part}-${one}`} flexDirection="row" paddingLeft={2} hover={LIT_BOX}>
514 <Button
515 key={`check-${name}-${part}-${one}`}
516 label={`${isKept ? '[x]' : '[ ]'} ${truncate(one === HERE ? `this folder (${now?.here.name ?? 'the session'})` : one, width - 14)}`}
517 plain
518 hover={LIT_TEXT}
519 onPress={() => setRule({ mode: 'only', only: isKept ? kept.filter(other => other.toLowerCase() !== one.toLowerCase()) : [...kept, one] })}
520 />
521 </Box>
522 )
523 })}
524 </Box>
525 )
526 }
527
528 const rulesOf = (name: string) => (shown?.rules ?? []).map((rule, index) => ({ rule, index })).filter(({ rule }) => rule.profile === name)
529
530 // What turns a profile on by itself: its rules, and the ones a press adds from where this
531 // session is; hours and days are asked in words to Claude.
532 const rulesBlock = (name: string) => {
533 const wifi = shown?.wifi ?? ''
534 const adds: [string, string[]][] = [
535 ...(wifi !== '' ? [[`+ on Wi-Fi ${truncate(wifi, 16)}`, [`wifi=${wifi}`]] as [string, string[]]] : []),
536 [`+ in ${truncate(now?.here.name ?? 'this folder', 16)}`, [`folder=${now?.here.path ?? ''}`]],
537 ['+ week-ends', ['days=weekend']],
538 ]
539
540 return (
541 <Box key={`rules-${name}`} flexDirection="column" paddingLeft={4}>
542 <Text dimColor>Turns on by itself:</Text>
543 {rulesOf(name).length === 0 && (
544 <Box paddingLeft={2}>
545 <Text dimColor>never (by hand only)</Text>
546 </Box>
547 )}
548 {rulesOf(name).map(({ rule, index }) => (
549 <Box key={`rule-${index}`} flexDirection="row" justifyContent="space-between" paddingLeft={2}>
550 <Text>{truncate(ruleText(rule), width - 20)}</Text>
551 {confirmButton(`unrule|${index}`, 'delete', () => void change($, ['unrule', String(index + 1)], true))}
552 </Box>
553 ))}
554 <Box flexDirection="row" gap={1} paddingLeft={2}>
555 {adds.map(([label, pairs]) => (
556 <Button key={`add-${name}-${label}`} label={label} dimColor onPress={() => void change($, ['rule', name, ...pairs], true)} />
557 ))}
558 </Box>
559 </Box>
560 )
561 }
562
563 // A profile's line says what it hides and whether a rule turns it on; its text opens the editor.
564 const profileRow = (name: string, profile: DndProfile) => {
565 const key = `profile|${name}`
566 const isOpen = open[key] ?? false
567 const isCurrent = now?.profile === name
568 const ruled = rulesOf(name).length > 0 ? ' (rule)' : ''
569 const buttons = [
570 isCurrent ? (
571 <Text key={`on-${name}`} color="green">on</Text>
572 ) : (
573 <Button key={`use-${name}`} label="use" onPress={() => void ask($, name)} />
574 ),
575 <Button key={`edit-${name}`} label={isOpen ? 'done' : 'edit'} dimColor={!isOpen} onPress={() => toggle(key)} />,
576 confirmButton(`delete|${name}`, 'delete', () => void change($, ['delete', name])),
577 ]
578
579 return (
580 <Box key={`row-${name}`} flexDirection="column" paddingLeft={2}>
581 {foldLine(key, `${name}${ruled} · ${summary(profile)}`, isOpen, buttons, 24)}
582 {isOpen && PARTS.map(({ part, title }) => partBlock(name, profile, part, title))}
583 {isOpen && rulesBlock(name)}
584 </Box>
585 )
586 }
587
588 const profilesSection = () => {
589 const entries = Object.entries(shown?.profiles ?? {})
590
591 return (
592 <Box key="profiles" flexDirection="column" marginTop={1}>
593 <Text bold>{`Profiles (${entries.length})`}</Text>
594 {entries.map(([name, profile]) => profileRow(name, profile))}
595 </Box>
596 )
597 }
598
599 // A new profile opens in its editor, ready to pick what it keeps.
600 const create = (value: string) => {
601 const name = value.trim().replace(/\s+/g, '-')
602
603 if (name !== '' && name !== 'none') {
604 void change($, ['set', name, 'history', 'show'], true).then(() => update($, unfolded, all => ({ ...all, [`profile|${name}`]: true })))
605 }
606 }
607
608 // One press per profile, then until when and where. `none` (everything shows) is only offered
609 // while a rule has a profile on, beside the status: it quiets the rules for a while.
610 const turnOnRow = () => {
611 const names = Object.keys(shown?.profiles ?? {})
612
613 return (
614 <Box key="turn-on" flexDirection="column" marginTop={1}>
615 <Text bold>Turn on</Text>
616 <Box flexDirection="row" flexWrap="wrap" gap={1} paddingLeft={2}>
617 {names.length === 0 && <Text dimColor>no profile yet: create one below</Text>}
618 {names.map(name =>
619 now?.profile === name ? (
620 <Text key={`current-${name}`} color="green">{`${name} (on)`}</Text>
621 ) : (
622 <Button key={`turn-on-${name}`} label={name} onPress={() => void ask($, name)} />
623 )
624 )}
625 </Box>
626 <Box paddingLeft={2} marginTop={1}>
627 <Input key="new-profile" label="New profile " placeholder="a name, e.g. work, meeting" submitLabel="create" onSubmit={create} />
628 </Box>
629 </Box>
630 )
631 }
632
633 const isManual = now?.scope === 'session' || now?.scope === 'computer' || now?.scope === 'everywhere'
634
635 return (
636 <Box flexDirection="column">
637 <Box flexDirection="row" justifyContent="flex-end" marginBottom={1}>
638 <Button key="mod-settings" label="Mod settings ›" dimColor onPress={() => void $.ui.open({ id: CONFIGURATION_PANE, title: 'Configuration', columns: 64 })} />
639 </Box>
640 <Text dimColor={now?.profile === ''}>{status()}</Text>
641 <Box flexDirection="row" justifyContent="flex-end">
642 <Box flexDirection="row" gap={1}>
643 {isManual && <Button key="off" label="off" onPress={() => void turnOff($)} />}
644 {now?.scope === 'rule' && <Button key="quiet-rules" label="quiet the rules" dimColor onPress={() => void ask($, 'none')} />}
645 {isBusy ? <Text dimColor>…</Text> : <Button key="refresh" label="refresh" dimColor onPress={() => void load($)} />}
646 </Box>
647 </Box>
648 {waiting !== '' && pendingBlock()}
649 {shown !== null && waiting === '' && turnOnRow()}
650 {shown !== null && profilesSection()}
651 {shown !== null && (
652 <Box marginTop={1}>
653 <Text dimColor>{truncate(`Saved in ${shown.file}`, width)}</Text>
654 </Box>
655 )}
656 </Box>
657 )
658 })
659}
660types/index.d.ts 69 lines1/** The panes a profile quiets: the history, the dev activity, the reminders, the handoffs. */
2export type DndPart = 'history' | 'prs' | 'reminders' | 'handoffs'
3
4/**
5 * `only`: the names kept (repos, groups, folders; `@here` is the session's folder). `keep`, in what
6 * is on: those names as the panes match them, lowercase, the last part of a path, @here resolved.
7 */
8export type DndPartRule = { mode?: 'show' | 'hide' | 'only'; only?: string[]; keep?: string[] }
9
10export type DndProfile = Partial<Record<DndPart, DndPartRule>>
11
12/** A rule turning a profile on when all its keys hold. */
13export type DndRule = { profile: string; wifi?: string; folder?: string; days?: string; from?: string; to?: string }
14
15/** What is on now for this session, as `dnd.sh active` prints it; `profile` '' when nothing is. */
16export type DndActive = {
17 profile: string
18 why: string
19 /** Epoch seconds the switch ends, 0 when it lasts. */
20 until: number
21 untilText: string
22 /** `session`, `computer` or `everywhere` when turned on by hand, `rule` when a rule did, '' when off. */
23 scope: string
24 parts: DndProfile
25 /** The session's repo, else its folder. */
26 here: { path: string; name: string }
27 wifi: string
28}
29
30/** Everything the pane shows, as `dnd.sh json` prints it. */
31export type DndData = {
32 profiles: Record<string, DndProfile>
33 rules: DndRule[]
34 active: DndActive
35 /** By part: the names it lists now, offered in a profile's checklist. */
36 known: Partial<Record<DndPart, string[]>>
37 /** Every repo and folder worked in (the config sync's, the dev folder's), offered in every checklist. */
38 workspaces?: string[]
39 /** The Wi-Fi network this computer is on, '' when none or unknown. */
40 wifi: string
41 file: string
42}
43
44declare module 'claude-code' {
45 interface PluginState {
46 /** The configuration mod's low power mode, read under its shape `low-power/1`. */
47 configuration: {
48 power: Shaped<{ setting: string; isLow: boolean } | null>
49 }
50 dnd: {
51 /**
52 * What the other panes read to hide or filter what they show; null before the first read.
53 * Kept under the shape `dnd-active/1`: a pane naming another shape reads it as absent and
54 * shows everything, so a change of its fields bumps the shape in every mod that reads it.
55 */
56 active: Shaped<DndActive | null>
57 data: DndData | null
58 problem: string
59 /** A profile to turn on, asked until when and where in the pane: `<profile>|<for>|<scope>`. */
60 pending: string
61 /** Profiles and their parts (`<profile>|<part>`) unfolded in the pane. */
62 unfolded: Record<string, boolean>
63 /** `<what>|<name>` pressed once: a second press confirms. */
64 armed: string
65 busy: boolean
66 }
67 }
68}
69