SLOPSHOPPER

dnd

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…

newpanecommandtoastprocesstimer
v0.6.0MITupdated 2026-10-25robinalexandre/claude-code-kit/mods/dnd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dnd
│ ┃ Do not disturb ✕ › fix the failing auth test and add an audit log call │ ┃ [ Mod settings › ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Could not read the profiles: SyntaxError: ⎿ Read 6 lines │ ┃ JSON Parse… ⏺ Update(src/auth.ts) │ ┃ [ refresh ] ⎿ 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 │ │ › /dnd │ ⎿ dnd: Do not disturb pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Do not disturb
[ Mod settings › ] Could not read the profiles: SyntaxError: JSON Parse… [ refresh ]
README

claude-code-kit

tests

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.

Install

As Claude Code plugins (recommended)

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.

With install.sh

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:

  • the same as another one: its parts, and its answers taken as they are. Only secrets and the questions about this computer are asked: its name, its repos folder, the ~/.zshrc line, the status line it has.
  • starting from another one: the checklist and every question, with the other computer's picks and answers filled in, to keep or change.
  • from scratch, as before.

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.

What's inside

Base rules

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.

Skills

SkillWhat it doesNeeds
test-evidenceCaptures 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-reviewProposes, 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-builderTurns 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.
handoffWrites 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-syncSchedules 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
kbA 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-watchTracks 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

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

ModWhat it does
paste-previewShows 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-paneA 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-handoffsOne 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-promptsShows 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.
dndDo 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

Source 2 files
hooks/register.tsx 660 lines
1import { 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}
660
types/index.d.ts 69 lines
1/** 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