SLOPSHOPPER

reminders-handoffs

One pane, with a tab for each of the reminders and handoff skills you have: reminders due now, upcoming and past; handoff notes open and done; filed by group…

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reminders-handoffs
│ ┃ reminders-handoffs ✕ › fix the failing auth test and add an audit log call │ ┃ Neither the reminders nor the handoff skill │ ┃ is installed. ⏺ Read(src/auth.ts) │ ┃ Loading… [ refresh ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · reminders-handoffs
Neither the reminders nor the handoff skill is installed. Loading… [ 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 1008 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DndActive, Handoff, Reminder, Source } from '../types'
5
6const PANE = 'reminders-handoffs'
7const RELOAD_EVERY_MS = 60_000
8const BODY_LINES = 40
9// Under the pointer, what folds turns to the theme's suggestion color with the text drawn on it in
10// the theme's inverse text color: readable in a light and a dark theme alike.
11const LIT_BOX = { backgroundColor: 'suggestion' }
12const LIT_TEXT = { color: 'inverseText', dimColor: false }
13// At the end of a line that folds others: unfold everything under it, or fold it all back.
14const UNFOLD_ALL = '▾ all'
15const FOLD_ALL = '▴ all'
16
17// The hover group a fold-all control lights, named after its key.
18const foldScope = (key: string) => `fold:${key}`.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 64)
19
20const reminders = atom({ plugin: 'reminders-handoffs', key: 'reminders' } as const, null)
21const handoffs = atom({ plugin: 'reminders-handoffs', key: 'handoffs' } as const, null)
22const enabled = atom({ plugin: 'reminders-handoffs', key: 'enabled' } as const, [])
23const problems = atom({ plugin: 'reminders-handoffs', key: 'problems' } as const, {})
24const tab = atom({ plugin: 'reminders-handoffs', key: 'tab' } as const, 'reminders')
25const openIds = atom({ plugin: 'reminders-handoffs', key: 'openIds' } as const, {})
26const openSections = atom({ plugin: 'reminders-handoffs', key: 'openSections' } as const, {})
27const armed = atom({ plugin: 'reminders-handoffs', key: 'armed' } as const, '')
28const busy = atom({ plugin: 'reminders-handoffs', key: 'busy' } as const, '')
29const loadedTime = atom({ plugin: 'reminders-handoffs', key: 'loadedTime' } as const, 0)
30
31// Each part this pane shows: the skill that keeps it, its script, and the pane's words for it.
32const SOURCES: Record<Source, { skill: string; script: string; title: string; command: string }> = {
33  reminders: { skill: 'reminders', script: 'reminders.sh', title: 'Reminders', command: 'reminders-pane' },
34  handoffs: { skill: 'handoff', script: 'handoff.sh', title: 'Handoffs', command: 'handoffs-pane' },
35}
36
37// The band's buttons share one row under everything above the prompt, whatever order the mods
38// draw in: each takes that row out of what the mods beneath drew, adds its own button, and sorts
39// them by key (band-1-history, band-2-prs, band-3-reminders).
40const BAND_ROW = 'band-buttons'
41
42type Drawn = { type?: string; props?: { key?: string }; children?: unknown[] }
43
44const keyOf = (node: unknown) => (node !== null && typeof node === 'object' ? ((node as Drawn).props?.key ?? '') : '')
45
46const byBandKey = (one: unknown, other: unknown) => keyOf(one).localeCompare(keyOf(other))
47
48const takeBandRow = (tree: unknown): { rest: unknown; buttons: unknown[] } => {
49  if (Array.isArray(tree)) {
50    let buttons: unknown[] = []
51    const rest = tree.map(child => {
52      if (buttons.length > 0) {
53        return child
54      }
55
56      const taken = takeBandRow(child)
57      buttons = taken.buttons
58
59      return taken.rest
60    })
61
62    // The engine reads every child of a drawn tree: the row taken out leaves no hole.
63    return { rest: rest.filter(child => child !== null && child !== undefined), buttons }
64  }
65
66  const node = tree as Drawn | null
67
68  if (node === null || typeof node !== 'object' || !Array.isArray(node.children)) {
69    return { rest: tree, buttons: [] }
70  }
71
72  if (node.props?.key === BAND_ROW) {
73    return { rest: null, buttons: node.children.flat(Infinity) }
74  }
75
76  const taken = takeBandRow(node.children)
77
78  return taken.buttons.length > 0 ? { rest: { ...node, children: taken.rest }, buttons: taken.buttons } : { rest: tree, buttons: [] }
79}
80
81const slashed = (path: string) => path.replace(/\\/g, '/')
82
83const truncate = (text: string, width: number) =>
84  text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
85
86// A line cut into lines of at most WIDTH columns, at a space when one is near the end.
87const wrap = (line: string, width: number): string[] => {
88  const lines: string[] = []
89  let rest = line
90  while (width > 0 && rest.length > width) {
91    const space = rest.lastIndexOf(' ', width)
92    const cut = space > width / 2 ? space : width
93    lines.push(rest.slice(0, cut).trimEnd())
94    rest = rest.slice(cut).trimStart()
95  }
96  return [...lines, rest]
97}
98
99const tail = (path: string) => slashed(path).replace(/\/+$/, '').split('/').pop() ?? path
100
101// The do-not-disturb profile on now, when the dnd mod is installed: it hides a part, or keeps only
102// what belongs to some groups, repos or the session's folder. Read under dnd's shape: another one
103// (an older or newer dnd) reads as nothing on, and everything shows.
104const dndActive = atom({ plugin: 'dnd', key: 'active' } as const, null, { shape: 'dnd-active/1' })
105
106const dndRule = (on: DndActive | null, source: Source) => (on?.profile ? on.parts?.[source] : undefined)
107
108// dnd hands the names kept as they are matched: lowercase, the last part of a path.
109const dndShows = (on: DndActive | null, source: Source, names: string[]) => {
110  const rule = dndRule(on, source)
111
112  if (rule?.mode === 'hide') {
113    return false
114  }
115
116  return rule?.mode !== 'only' || names.some(name => name !== '' && (rule.keep ?? []).includes(tail(name).toLowerCase()))
117}
118
119// The parts and the rows the profile on now lets through.
120const dndView = async ($: EngineInterface) => {
121  const on = await read($, dndActive)
122  const all = { reminders: await read($, reminders), handoffs: await read($, handoffs) }
123
124  return {
125    on,
126    found: (await read($, enabled)).filter(source => dndRule(on, source)?.mode !== 'hide'),
127    reminders: all.reminders?.filter(one => dndShows(on, 'reminders', [reminderGroup(one), one.where ?? ''])) ?? null,
128    handoffs: all.handoffs?.filter(note => dndShows(on, 'handoffs', [handoffGroup(note), note.repo ?? ''])) ?? null,
129    isFiltered: (['reminders', 'handoffs'] as Source[]).some(source => dndRule(on, source)?.mode === 'only'),
130  }
131}
132
133// Buttons holding styled Text draw from 2.1.306 on, the oldest build checked; an older one, or a
134// version that cannot be read, gets plain labels.
135const STYLED_SINCE = [2, 1, 306]
136
137let styledFound: boolean | undefined
138
139const isStyled = async ($: EngineInterface) => {
140  if (styledFound === undefined) {
141    try {
142      const parts = ((await $.session.version()).base ?? '').replace(/-.*$/, '').split('.').map(Number)
143      const at = parts.findIndex((part, index) => part !== STYLED_SINCE[index])
144
145      styledFound = parts.length === 3 && parts.every(Number.isInteger) && (at === -1 || (parts[at] ?? 0) > (STYLED_SINCE[at] ?? 0))
146    } catch {
147      styledFound = false
148    }
149  }
150
151  return styledFound
152}
153
154const home = async ($: EngineInterface) => slashed((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '~')
155
156const runs = async ($: EngineInterface, argv: string[]) => {
157  try {
158    return (await $.process.run(argv, { timeoutMs: 10_000 })).exitCode === 0
159  } catch {
160    return false
161  }
162}
163
164// On Windows, Claude Code's own PATH has Git's cmd folder (git) but not bash: Git Bash is found
165// from the variable Claude Code reads for it, then from git's own install folder.
166let bashFound: string | undefined
167
168const bash = async ($: EngineInterface) => {
169  if (bashFound !== undefined) {
170    return bashFound
171  }
172
173  const fromGit = async () => {
174    const execPath = await $.process.run(['git', '--exec-path'], { timeoutMs: 10_000 }).catch(() => undefined)
175    const root = slashed(execPath?.stdout.trim() ?? '').replace(/\/(mingw64|clangarm64|mingw32)\/.*$/, '')
176
177    return execPath?.exitCode === 0 && root !== '' ? `${root}/bin/bash.exe` : ''
178  }
179  const candidates = [
180    async () => slashed((await $.env.get('CLAUDE_CODE_GIT_BASH_PATH')) ?? ''),
181    async () => 'bash',
182    fromGit,
183    async () => `${slashed((await $.env.get('ProgramFiles')) ?? 'C:/Program Files')}/Git/bin/bash.exe`,
184  ]
185
186  for (const candidate of candidates) {
187    const path = await candidate()
188
189    if (path !== '' && (await runs($, [path, '-c', 'exit 0']))) {
190      bashFound = path
191
192      return path
193    }
194  }
195
196  throw new Error('Git Bash not found: set CLAUDE_CODE_GIT_BASH_PATH to bash.exe')
197}
198
199// The skill copied into ~/.claude by install.sh, else installed as a plugin from the kit's
200// marketplace; undefined when neither has the script (the part is not activated).
201const scriptOf = async ($: EngineInterface, source: Source) => {
202  const { skill, script } = SOURCES[source]
203  const copied = `${await home($)}/.claude/skills/${skill}/${script}`
204
205  if (await $.fs.exists(copied).catch(() => false)) {
206    return copied
207  }
208
209  const config = slashed((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await home($)}/.claude`)
210  const installed = await $.fs
211    .read(`${config}/plugins/installed_plugins.json`)
212    .then(text => JSON.parse(String(text)) as { plugins?: Record<string, { installPath?: string }[]> })
213    .catch(() => undefined)
214  const root = installed?.plugins?.[`${skill}@claude-code-kit`]?.[0]?.installPath
215  const fromPlugin = root ? `${slashed(root)}/skills/${skill}/${script}` : ''
216
217  return fromPlugin !== '' && (await $.fs.exists(fromPlugin).catch(() => false)) ? fromPlugin : undefined
218}
219
220const scripts: Partial<Record<Source, string>> = {}
221
222const detect = async ($: EngineInterface) => {
223  const found: Source[] = []
224
225  for (const source of Object.keys(SOURCES) as Source[]) {
226    const path = await scriptOf($, source)
227
228    if (path !== undefined) {
229      scripts[source] = path
230      found.push(source)
231    }
232  }
233
234  await update($, enabled, () => found)
235
236  if (!found.includes(await read($, tab)) && found[0] !== undefined) {
237    await update($, tab, () => found[0] as Source)
238  }
239
240  return found
241}
242
243const run = async ($: EngineInterface, source: Source, args: string[]) =>
244  $.process.run([await bash($), scripts[source] ?? '', ...args], { timeoutMs: 30_000 })
245
246const loadOne = async ($: EngineInterface, source: Source) => {
247  const setProblem = (text: string) => update($, problems, all => ({ ...all, [source]: text }))
248
249  try {
250    const args = source === 'reminders' ? ['json', slashed(await $.session.cwd())] : ['json']
251    const ran = await run($, source, args)
252
253    if (ran.exitCode !== 0) {
254      await setProblem((ran.stderr.trim() || `${SOURCES[source].script} exited with ${ran.exitCode}`).split('\n')[0] ?? '')
255
256      return
257    }
258
259    if (source === 'reminders') {
260      await update($, reminders, () => JSON.parse(ran.stdout) as Reminder[])
261    } else {
262      await update($, handoffs, () => JSON.parse(ran.stdout) as Handoff[])
263    }
264
265    await setProblem('')
266  } catch (error) {
267    await setProblem(String(error).split('\n')[0] ?? '')
268  }
269}
270
271const load = async ($: EngineInterface) => {
272  for (const source of await read($, enabled)) {
273    await loadOne($, source)
274  }
275
276  const now = await $.clock.now()
277  await update($, loadedTime, () => now)
278  await scheduleDue($)
279}
280
281// "YYYY-MM-DD" or "YYYY-MM-DD HH:MM", in local time.
282const stampMs = (stamp: string) => {
283  const [day = '', time = '00:00'] = stamp.split(' ')
284
285  return new Date(`${day}T${time}`).getTime()
286}
287
288// A long wait is cut short: the engine's timers may not hold days, and each load sets the next.
289const DUE_WAIT_MAX_MS = 6 * 60 * 60_000
290let nextDue: { cancel: () => void } | undefined
291
292// The lists are read again the moment the next reminder here falls due, so the band says it then.
293const scheduleDue = async ($: EngineInterface) => {
294  const now = await $.clock.now()
295  const times = ((await read($, reminders)) ?? [])
296    .filter(one => one.state === 'later')
297    .map(one => stampMs(one.next ?? one.on ?? ''))
298    .filter(ms => ms > now)
299  nextDue?.cancel()
300
301  if (times.length > 0) {
302    nextDue = $.clock.after(Math.min(Math.min(...times) - now + 1_000, DUE_WAIT_MAX_MS), () => void load($).catch(() => undefined))
303  }
304}
305
306const clockTime = (milliseconds: number) => {
307  const date = new Date(milliseconds)
308
309  return [date.getHours(), date.getMinutes()].map(part => String(part).padStart(2, '0')).join(':')
310}
311
312let loadedAt = 0
313
314// Detects again each time: a /clear or a reload can leave the session with no part enabled,
315// which would hide the band's button for the rest of it.
316const reload = async ($: EngineInterface) => {
317  const now = await $.clock.now()
318
319  if (now - loadedAt >= RELOAD_EVERY_MS) {
320    loadedAt = now
321    await detect($)
322    await load($)
323  }
324}
325
326const syncNow = async ($: EngineInterface) => {
327  const sync = `${await home($)}/.claude-sync/sync.sh`
328
329  return $.process.run([await bash($), '-c', `[ -f "${sync}" ] && bash "${sync}" now`], { timeoutMs: 120_000 }).catch(() => undefined)
330}
331
332// The sync asks the remote whether another computer pushed (sync.sh poll: one cheap request, and one
333// check per computer however many sessions ask), every minute while the pane is open, every five
334// minutes otherwise. Whichever session pulled, the others see the new head and read again; with
335// nothing pulled, lists older than five minutes are read again all the same, for the band.
336const POLL_OPEN_MS = 60_000
337const POLL_CLOSED_MS = 5 * 60_000
338let nextPoll: { cancel: () => void } | undefined
339let seenHead = ''
340
341// In the kit's low power mode (the configuration mod's, read under its shape), five times slower.
342const LOW_POWER_FACTOR = 5
343const lowPower = atom({ plugin: 'configuration', key: 'power' } as const, null, { shape: 'low-power/1' })
344
345const schedulePoll = async ($: EngineInterface) => {
346  const isShown = (await $.ui.panes().catch(() => [])).some(pane => pane.id === PANE)
347  const factor = (await read($, lowPower))?.isLow === true ? LOW_POWER_FACTOR : 1
348  nextPoll?.cancel()
349  nextPoll = $.clock.after((isShown ? POLL_OPEN_MS : POLL_CLOSED_MS) * factor, () => void poll($).catch(() => undefined))
350}
351
352const poll = async ($: EngineInterface) => {
353  await schedulePoll($)
354  const sync = `${await home($)}/.claude-sync/sync.sh`
355  const ran = await $.process
356    .run([await bash($), '-c', `[ -f "${sync}" ] && bash "${sync}" poll`], { timeoutMs: 120_000 })
357    .catch(() => undefined)
358  const head = /^head (\S+)$/m.exec(ran?.stdout ?? '')?.[1] ?? ''
359  const isPulled = ran?.stdout.includes('main moved') === true || (head !== '' && seenHead !== '' && head !== seenHead)
360  seenHead = head || seenHead
361  const isOld = (await $.clock.now()) - (await read($, loadedTime)) >= POLL_CLOSED_MS
362
363  if (isPulled || isOld) {
364    loadedAt = await $.clock.now()
365    await load($)
366  }
367}
368
369// The other computers get the change with the next sync; it runs in the background.
370const shareChange = async ($: EngineInterface) => {
371  void syncNow($)
372}
373
374const REFRESH = 'refresh'
375
376// What is on this computer first, then again once the sync has brought what the others wrote.
377const refresh = async ($: EngineInterface) => {
378  if ((await read($, busy)) === REFRESH) {
379    return
380  }
381
382  await update($, busy, () => REFRESH)
383
384  try {
385    loadedAt = await $.clock.now()
386    await detect($)
387    await load($)
388    await syncNow($)
389    await load($)
390  } finally {
391    await update($, busy, () => '')
392  }
393}
394
395type Action = 'done' | 'stop' | 'delete' | 'cancel' | 'restore'
396
397const CONFIRMED: Action[] = ['stop', 'delete', 'cancel']
398
399// stop, delete and cancel ask for a second press; done and restore act at once.
400const act = async ($: EngineInterface, source: Source, action: Action, id: string) => {
401  const key = `${source}|${action}|${id}`
402
403  if (CONFIRMED.includes(action) && (await read($, armed)) !== key) {
404    await update($, armed, () => key)
405
406    return
407  }
408
409  await update($, armed, () => '')
410  await update($, busy, () => key)
411
412  try {
413    const ran = await run($, source, [action, id])
414
415    if (ran.exitCode !== 0) {
416      $.ui.toast(`Could not ${action} it: ${ran.stderr.trim().split('\n')[0] ?? ''}`)
417    }
418
419    await loadOne($, source)
420    await shareChange($)
421  } finally {
422    await update($, busy, () => '')
423  }
424}
425
426// The handoff skill picks the note up in this session, as if the user had asked.
427const pick = async ($: EngineInterface, source: Source, item: { id: string; title: string }) => {
428  const text =
429    source === 'reminders'
430      ? `Let's do the reminder ${item.id} now: "${item.title}".`
431      : `Pick up the handoff ${item.id}: "${item.title}".`
432  await $.prompt.submit({ text })
433}
434
435const openPane = async ($: EngineInterface, source?: Source) => {
436  const found = await read($, enabled)
437  const title = found.map(one => SOURCES[one].title).join(' & ') || 'Reminders'
438
439  if (source !== undefined) {
440    await update($, tab, () => source)
441  }
442
443  await $.ui.open({ id: PANE, title, columns: 72 })
444  await load($)
445  // Open, it is asked every minute from now on.
446  await schedulePoll($)
447}
448
449const dayText = (stamp: string) => {
450  const [day = '', time = ''] = stamp.split(' ')
451  const [, month = '', date = ''] = day.split('-')
452
453  return `${date}/${month}${time !== '' && time !== '00:00' ? ` ${time}` : ''}`
454}
455
456const isRepeating = (one: Reminder) => (one.every ?? '') !== ''
457
458// What a reminder's line says about when: now, next time, since when it ended.
459const whenText = (one: Reminder) => {
460  if (one.state === 'past') {
461    return one.closed ? `closed ${dayText(one.closed)}` : 'closed'
462  }
463
464  if (one.state === 'expired') {
465    return one.until ? `ended ${dayText(one.until)}` : 'ended'
466  }
467
468  const repeat = isRepeating(one) ? `every ${one.every}` : ''
469
470  // Its repeat days are in its details: the line keeps room for the title beside three buttons.
471  if (one.state === 'due') {
472    return 'now'
473  }
474
475  const next = one.next ?? one.on ?? ''
476  const at = next !== '' ? dayText(next) : ''
477  const where = [one.where ? `in ${tail(one.where)}` : '', one.machine ? `on ${one.machine}` : ''].filter(part => part !== '')
478  const place = one.state === 'elsewhere' && where.length > 0 ? `only ${where.join(' ')}` : ''
479
480  return [at, repeat, place].filter(part => part !== '').join(' · ')
481}
482
483const reminderSection = (one: Reminder) =>
484  one.state === 'due' ? 'now' : one.state === 'past' || one.state === 'expired' ? 'past' : 'upcoming'
485
486// A reminder is filed under its group (an organization, a client, a project), else the folder it
487// is for, else the computer, else General; a handoff under its repo, else General.
488const reminderGroup = (one: Reminder) =>
489  one.group ?? (one.where ? tail(one.where) : one.machine ? `${one.machine} only` : 'General')
490
491const handoffGroup = (note: Handoff) => (note.repo ? tail(note.repo) : 'General')
492
493const byGroup = <T,>(rows: T[], groupOf: (row: T) => string) => {
494  const groups = new Map<string, T[]>()
495
496  for (const row of rows) {
497    groups.set(groupOf(row), [...(groups.get(groupOf(row)) ?? []), row])
498  }
499
500  return [...groups.entries()].sort(([one], [other]) =>
501    one === 'General' ? 1 : other === 'General' ? -1 : one.localeCompare(other)
502  )
503}
504
505const counts = (shownReminders: Reminder[] | null, shownHandoffs: Handoff[] | null) => ({
506  due: (shownReminders ?? []).filter(one => one.state === 'due').length,
507  current: (shownReminders ?? []).filter(one => reminderSection(one) !== 'past').length,
508  open: (shownHandoffs ?? []).filter(note => note.state === 'open').length,
509  fromElsewhere: (shownHandoffs ?? []).filter(note => note.state === 'open' && note.isMine !== true).length,
510})
511
512const start = async ($: EngineInterface) => {
513  // A name already taken (a skill or command of the user's) costs that command only.
514  for (const source of await detect($)) {
515    await $.command
516      .register({
517        name: SOURCES[source].command,
518        description:
519          source === 'reminders'
520            ? 'Show the reminders pane: due now, upcoming and repeating, past; pick, close or stop one'
521            : 'Show the handoff notes: open ones to pick up or cancel, done ones',
522      })
523      .catch(() => undefined)
524  }
525
526  loadedAt = Number.NEGATIVE_INFINITY
527  styledFound = undefined
528  void reload($).catch(() => undefined)
529  void schedulePoll($)
530}
531
532export const register: Register = on => {
533  on('session.start', async ($, e, next) => {
534    await start($)
535
536    return next(e)
537  })
538
539  // A /clear starts a fresh session without a session.start: the band is set up again once it has.
540  on('session.end', async ($, e, next) => {
541    const ended = await next(e)
542
543    if (e.reason === 'clear') {
544      $.clock.after(0, () => void start($))
545    }
546
547    return ended
548  })
549
550  on('turn.complete', async ($, e, next) => {
551    void reload($).catch(() => undefined)
552
553    return next(e)
554  })
555
556  on('command.run', { command: 'reminders-pane' }, async $ => {
557    await openPane($, 'reminders')
558
559    return { text: 'Reminders pane opened.' }
560  })
561
562  on('command.run', { command: 'handoffs-pane' }, async $ => {
563    await openPane($, 'handoffs')
564
565    return { text: 'Handoffs pane opened.' }
566  })
567
568  // One button at the right of the band above the prompt, named after the parts activated, even
569  // with nothing in them: yellow when a reminder is due or a note from the other computer waits.
570  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
571    const beneath = await next(e)
572    const view = await dndView($)
573    const found = view.found
574    const { due, open, fromElsewhere } = counts(view.reminders, view.handoffs)
575
576    if ((await read($, enabled)).length === 0) {
577      void reload($).catch(() => undefined)
578    }
579
580    if (e.props.hasSurvey || e.props.view.agentId !== undefined || found.length === 0) {
581      return beneath
582    }
583
584    const { Box, Button, Text } = $.ui.resolve(e)
585    const isUrgent = due + fromElsewhere > 0
586    const parts = found.map(source =>
587      source === 'reminders' ? `Reminders${due > 0 ? ` ${due} now` : ''}` : `Handoffs${open > 0 ? ` ${open}` : ''}`
588    )
589    const first = due > 0 || fromElsewhere === 0 ? found[0] : 'handoffs'
590
591    const { rest, buttons } = takeBandRow(beneath)
592    const mine = (
593      <Box key="band-3-reminders" flexDirection="row">
594        {isUrgent && <Text color="yellow">● </Text>}
595        <Button key="reminders-handoffs" label={parts.join(' · ')} dimColor={!isUrgent} onPress={() => void openPane($, first)} />
596      </Box>
597    )
598
599    return (
600      <Box flexDirection="column">
601        {rest as never}
602        <Box key={BAND_ROW} flexDirection="row" gap={2}>
603          {[...buttons, mine].sort(byBandKey) as never}
604        </Box>
605      </Box>
606    )
607  })
608
609  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
610    const { Box, Button, Text } = $.ui.resolve(e)
611    const view = await dndView($)
612    const installed = await read($, enabled)
613    const found = view.found
614    const picked = await read($, tab)
615    const current = found.includes(picked) ? picked : (found[0] ?? picked)
616    const shownReminders = view.reminders
617    const shownHandoffs = view.handoffs
618    const trouble = (await read($, problems))[current] ?? ''
619    const unfolded = await read($, openIds)
620    const sections = await read($, openSections)
621    const armedKey = await read($, armed)
622    const busyKey = await read($, busy)
623    const lastLoad = await read($, loadedTime)
624    const styled = await isStyled($)
625    const width = Math.max(20, e.props.bodyColumns - 2)
626    const { due, current: remindersCount, open } = counts(shownReminders, shownHandoffs)
627
628    type Look = { bold?: boolean; underline?: boolean; dimColor?: boolean; color?: string }
629
630    const foldButton = (key: string, label: string, look: Look, onPress: () => void) =>
631      styled ? (
632        <Button key={key} plain onPress={onPress}>
633          <Text {...look}>{label}</Text>
634        </Button>
635      ) : (
636        <Button key={key} label={label} plain dimColor={look.dimColor} onPress={onPress} />
637      )
638
639    const actionButton = (source: Source, action: Action, id: string, label: string) => {
640      const key = `${source}|${action}|${id}`
641
642      if (busyKey === key) {
643        return <Text key={`${action}-${id}`} dimColor>{`${action}… `}</Text>
644      }
645
646      if (armedKey !== key) {
647        return <Button key={`${action}-${id}`} label={label} onPress={() => void act($, source, action, id)} />
648      }
649
650      return styled ? (
651        <Button key={`${action}-${id}`} onPress={() => void act($, source, action, id)}>
652          <Text color="red">{`${label}? press again`}</Text>
653        </Button>
654      ) : (
655        <Button key={`${action}-${id}`} label={`${label}? press again`} variant="primary" onPress={() => void act($, source, action, id)} />
656      )
657    }
658
659    const textLines = (id: string, meta: string[], body: string) => {
660      const lines = body
661        .split(/\r?\n/)
662        .map(line => line.replace(/[\x00-\x1f\x7f]/g, ' ').trimEnd())
663        .filter((line, index, all) => line !== '' || (index > 0 && all[index - 1] !== ''))
664        .flatMap(line => wrap(line, width - 4))
665      const shown = lines.length > BODY_LINES ? [...lines.slice(0, BODY_LINES), '…'] : lines
666
667      return (
668        <Box key={`details-${id}`} flexDirection="column" paddingLeft={4}>
669          {meta.filter(line => line !== '').map((line, index) => (
670            <Text key={`meta-${id}-${index}`} dimColor>
671              {truncate(line, width - 4)}
672            </Text>
673          ))}
674          {shown.map((line, index) => (
675            <Text key={`body-${id}-${index}`}>{line || ' '}</Text>
676          ))}
677        </Box>
678      )
679    }
680
681    // The buttons' labels set what is left for the title; when that leaves too little, the line drops
682    // its "when", which the details still show.
683    const row = (id: string, title: string, fullWhen: string, color: string, isStrong: boolean, isFaded: boolean, buttons: unknown[], labels: string[], details: () => unknown, closedBy: string) => {
684      const isOpen = unfolded[id] ?? false
685      const room = width - 6 - labels.reduce((sum, label) => sum + label.length + 3, 0)
686      const when = room - fullWhen.length - 3 >= Math.min(title.length, 12) ? fullWhen : ''
687      const titleWidth = Math.max(8, room - (when === '' ? 0 : when.length + 3))
688
689      const toggle = () => void update($, openIds, all => ({ ...all, [id]: !isOpen }))
690      const shownTitle = truncate(title, titleWidth)
691
692      // The whole line folds and unfolds the row, not only its arrow.
693      return (
694        <Box key={`row-${id}`} flexDirection="column" paddingLeft={2}>
695          <Box flexDirection="row" justifyContent="space-between">
696            {/* What folds lights up under the pointer; the buttons beside it do something else. */}
697            <Box key={`line-${id}`} flexDirection="row" flexShrink={1} hover={LIT_BOX}>
698              <Button key={`toggle-${id}`} label={isOpen ? '▾' : '▸'} plain hover={LIT_TEXT} onPress={toggle} />
699              <Text hover={LIT_TEXT}>
700                {' '}
701                <Text color={color} hover={LIT_TEXT}>
702                  ●{' '}
703                </Text>
704              </Text>
705              {styled ? (
706                <Button key={`title-${id}`} plain hover={LIT_TEXT} onPress={toggle}>
707                  <Text hover={LIT_TEXT}>
708                    <Text bold={isStrong} dimColor={isFaded} hover={LIT_TEXT}>
709                      {shownTitle}
710                    </Text>
711                    {when !== '' && (
712                      <Text dimColor hover={LIT_TEXT}>
713                        {` · ${when}`}
714                      </Text>
715                    )}
716                  </Text>
717                </Button>
718              ) : (
719                <Button key={`title-${id}`} label={`${shownTitle}${when !== '' ? ` · ${when}` : ''}`} plain dimColor={isFaded} hover={LIT_TEXT} onPress={toggle} />
720              )}
721            </Box>
722            <Box flexDirection="row" flexShrink={0} gap={1}>
723              {buttons as never}
724            </Box>
725          </Box>
726          {isOpen && (
727            <Box flexDirection="column">
728              {details() as never}
729              {closingBar(closedBy)}
730            </Box>
731          )}
732        </Box>
733      )
734    }
735
736    // pick hands the item to this session now; the other actions run the part's script.
737    const buttonsFor = (source: Source, item: { id: string; title: string }, specs: [Action | 'pick', string][]) => ({
738      buttons: specs.map(([action, label]) =>
739        action === 'pick' ? (
740          <Button key={`pick-${item.id}`} label={label} onPress={() => void pick($, source, item)} />
741        ) : (
742          actionButton(source, action, item.id, label)
743        )
744      ),
745      labels: specs.map(([, label]) => label),
746    })
747
748    const reminderRow = (one: Reminder, color: string, closedBy: string) => {
749      const { buttons, labels } = buttonsFor(
750        'reminders',
751        one,
752        one.state === 'past'
753          ? [['restore', 'restore'], ['delete', 'delete']]
754          : one.agent
755            ? [['stop', 'cancel']]
756            : one.state === 'due'
757            ? isRepeating(one)
758              ? [['pick', 'pick'], ['done', 'done today'], ['stop', 'stop']]
759              : [['pick', 'pick'], ['done', 'done']]
760            : [['pick', 'pick'], ['stop', isRepeating(one) ? 'stop' : one.state === 'expired' ? 'close' : 'cancel']]
761      )
762
763      return row(one.id, one.title, whenText(one), color, one.state === 'due', one.state === 'past', buttons, labels, () =>
764        textLines(
765          one.id,
766          [
767            one.agent ? `Run by ${one.agent} on its own` : '',
768            one.on ? `Starts: ${one.on}` : '',
769            one.every ? `Repeats: every ${one.every}${one.next ? ` · next ${one.next}` : ''}${one.done ? ` · last done ${one.done}` : ''}` : '',
770            one.until ? `Until: ${one.until}` : '',
771            one.where ? `Folder: ${one.where}` : '',
772            one.machine ? `Computer: ${one.machine}` : '',
773            one.group ? `Group: ${one.group}` : '',
774            one.closed ? `Closed: ${one.closed}` : '',
775          ],
776          one.body ?? ''
777        ),
778        closedBy
779      )
780    }
781
782    const handoffRow = (note: Handoff, color: string, closedBy: string) => {
783      const isOpenNote = note.state === 'open'
784      const when = [note.from ? (note.isMine ? 'from this computer' : `from ${note.from}`) : '', note.date ? dayText(note.date) : '', note.state === 'canceled' ? 'canceled' : '']
785        .filter(part => part !== '')
786        .join(' · ')
787      const { buttons, labels } = buttonsFor(
788        'handoffs',
789        note,
790        isOpenNote ? [['pick', 'pick'], ['cancel', 'cancel']] : [['restore', 'restore'], ['delete', 'delete']]
791      )
792
793      return row(note.id, note.title, when, color, isOpenNote && note.isMine !== true, !isOpenNote, buttons, labels, () =>
794        textLines(
795          note.id,
796          [
797            note.from ? `From: ${note.from}` : '',
798            note.date ? `Written: ${note.date}` : '',
799            note.repo ? `Repo: ${note.repo}` : '',
800            note.branch ? `Branch: ${note.branch}` : '',
801            note.closed ? `${note.state === 'canceled' ? 'Canceled' : 'Done'}: ${note.closed}` : '',
802          ],
803          note.body ?? ''
804        ),
805        closedBy
806      )
807    }
808
809    // What folds under a section or a group: its groups' keys and its rows' ids.
810    type Folds = { sectionKeys: string[]; ids: string[] }
811
812    const foldsOf = <T extends { id: string },>(section: string, rows: T[], groupOf: (row: T) => string): Folds => ({
813      sectionKeys: [section, ...byGroup(rows, groupOf).map(([name]) => `${section}|${name}`)],
814      ids: rows.map(row => row.id),
815    })
816
817    // Something shows open under the line: one of its groups, or, for a group, one of its rows.
818    const hasOpenUnder = (folds: Folds) => {
819      const [own = '', ...groups] = folds.sectionKeys
820      const children = groups.length > 0 ? groups.map(key => sections[key] ?? false) : folds.ids.map(id => unfolded[id] ?? false)
821
822      return (sections[own] ?? false) && children.some(Boolean)
823    }
824
825    // Unfolding opens the line and all under it; folding back closes what is under it, the line
826    // itself too when `isWhole`.
827    const setFolds = async (folds: Folds, isOpen: boolean, isWhole = false) => {
828      const keys = isOpen || isWhole ? folds.sectionKeys : folds.sectionKeys.slice(1)
829      await update($, openSections, all => ({ ...all, ...Object.fromEntries(keys.map(key => [key, isOpen])) }))
830      await update($, openIds, all => ({ ...all, ...Object.fromEntries(folds.ids.map(id => [id, isOpen])) }))
831    }
832
833    // A bar down the left edge of a block, shown only while the pointer is on the control that would
834    // fold the block back; hidden, it is never under the pointer itself.
835    const closingBar = (controlKey: string) => (
836      <Box
837        position="absolute"
838        top={0}
839        bottom={0}
840        left={0}
841        width={1}
842        display="none"
843        backgroundColor="suggestion"
844        hover={{ scope: foldScope(controlKey), display: 'flex' }}
845      />
846    )
847
848    // Drawn in the accent color where the build styles a button's text, named in words for a reader.
849    // Under the pointer it lights the bar beside each block it would fold back (closingBar).
850    const foldAllControl = (key: string, isOpen: boolean, drawn: string, onPress: () => void) =>
851      styled ? (
852        <Button key={key} label={isOpen ? 'collapse all' : 'expand all'} hover={{ scope: foldScope(key) }} onPress={onPress}>
853          <Text color="suggestion">{drawn}</Text>
854        </Button>
855      ) : (
856        <Button key={key} label={drawn} hover={{ scope: foldScope(key) }} onPress={onPress} />
857      )
858
859    // Anything open under the line folds it all back; all of it folded, the arrow unfolds everything.
860    const foldAllButton = (key: string, folds: Folds) => {
861      const isOpen = hasOpenUnder(folds)
862
863      return foldAllControl(`all-${key}`, isOpen, isOpen ? FOLD_ALL : UNFOLD_ALL, () => void setFolds(folds, !isOpen))
864    }
865
866    const foldLine = (fold: unknown, key: string, folds: Folds) => (
867      <Box flexDirection="row" justifyContent="space-between">
868        {fold as never}
869        {foldAllButton(key, folds)}
870      </Box>
871    )
872
873    // A section's rows, each group under a title that folds, even a lone one. Each row is told which
874    // control folds its details back: its group's.
875    const grouped = <T extends { id: string },>(section: string, rows: T[], groupOf: (row: T) => string, draw: (row: T, closedBy: string) => unknown) => {
876      const groups = byGroup(rows, groupOf)
877
878      return groups.map(([name, members]) => {
879        const key = `${section}|${name}`
880        const isOpen = sections[key] ?? false
881        const fold = foldButton(`group-${section}-${name}`, `${isOpen ? '▾' : '▸'} ${name} (${members.length})`, {}, () =>
882          void update($, openSections, all => ({ ...all, [key]: !isOpen }))
883        )
884
885        return (
886          <Box key={`group-${key}`} flexDirection="column" paddingLeft={2}>
887            {foldLine(fold, `group-${section}-${name}`, { sectionKeys: [key], ids: members.map(row => row.id) })}
888            {isOpen && (
889              <Box flexDirection="column">
890                {members.map(member => draw(member, `all-group-${section}-${name}`)) as never}
891                {closingBar(`all-${section}`)}
892              </Box>
893            )}
894          </Box>
895        )
896      })
897    }
898
899    // Sections and groups start folded; what the user unfolds stays so for the session.
900    const section = <T extends { id: string },>(id: string, title: string, rows: T[], groupOf: (row: T) => string, draw: (row: T, closedBy: string) => unknown) => {
901      const isOpen = sections[id] ?? false
902      const fold = foldButton(id, `${isOpen ? '▾' : '▸'} ${title} (${rows.length})`, { bold: true }, () =>
903        void update($, openSections, all => ({ ...all, [id]: !isOpen }))
904      )
905
906      return (
907        <Box key={`section-${id}`} flexDirection="column" marginTop={1}>
908          {rows.length === 0 ? fold : foldLine(fold, id, foldsOf(id, rows, groupOf))}
909          {isOpen && (
910            <Box flexDirection="column">
911              {rows.length === 0 && (
912                <Box paddingLeft={2}>
913                  <Text dimColor>nothing</Text>
914                </Box>
915              )}
916              {grouped(id, rows, groupOf, draw) as never}
917              {closingBar('fold-all')}
918            </Box>
919          )}
920        </Box>
921      )
922    }
923
924    const byNext = (one: Reminder, other: Reminder) => (one.next ?? one.on ?? '').localeCompare(other.next ?? other.on ?? '')
925    const byClosed = (one: { closed?: string }, other: { closed?: string }) => (other.closed ?? '').localeCompare(one.closed ?? '')
926
927    // Each tab's sections, drawn and folded all at once by the button beside refresh.
928    const remindersTab = () => {
929      const all = shownReminders ?? []
930      const rowsOf = (part: string) => all.filter(one => reminderSection(one) === part)
931
932      return {
933        drawn: [
934          section('now', 'Now', rowsOf('now').sort(byNext), reminderGroup, (one, closedBy) => reminderRow(one, 'yellow', closedBy)),
935          section('upcoming', 'Upcoming', rowsOf('upcoming').sort(byNext), reminderGroup, (one, closedBy) => reminderRow(one, 'cyan', closedBy)),
936          section('past', 'Past', rowsOf('past').sort(byClosed), reminderGroup, (one, closedBy) => reminderRow(one, 'gray', closedBy)),
937        ],
938        folds: ['now', 'upcoming', 'past'].map(part => foldsOf(part, rowsOf(part), reminderGroup)),
939      }
940    }
941
942    const handoffsTab = () => {
943      const all = shownHandoffs ?? []
944      const byDate = (one: Handoff, other: Handoff) => (other.date ?? '').localeCompare(one.date ?? '')
945      const openNotes = all.filter(note => note.state === 'open').sort(byDate)
946      const doneNotes = all.filter(note => note.state !== 'open').sort(byClosed)
947
948      return {
949        drawn: [
950          section('open-notes', 'Open', openNotes, handoffGroup, (note, closedBy) => handoffRow(note, 'yellow', closedBy)),
951          section('done-notes', 'Done', doneNotes, handoffGroup, (note, closedBy) => handoffRow(note, 'gray', closedBy)),
952        ],
953        folds: [foldsOf('open-notes', openNotes, handoffGroup), foldsOf('done-notes', doneNotes, handoffGroup)],
954      }
955    }
956
957    const loaded = current === 'reminders' ? shownReminders : shownHandoffs
958    const shownTab = loaded === null || found.length === 0 ? null : current === 'reminders' ? remindersTab() : handoffsTab()
959    const tabFolds: Folds = {
960      sectionKeys: (shownTab?.folds ?? []).flatMap(folds => folds.sectionKeys),
961      ids: (shownTab?.folds ?? []).flatMap(folds => folds.ids),
962    }
963    const isTabOpen = (shownTab?.folds ?? []).some(folds => sections[folds.sectionKeys[0] ?? ''] ?? false)
964    const tabLabel = (source: Source) =>
965      source === 'reminders' ? `Reminders (${remindersCount}${due > 0 ? `, ${due} now` : ''})` : `Handoffs (${open} open)`
966
967    return (
968      <Box flexDirection="column">
969        {found.length > 1 && (
970          <Box flexDirection="row" gap={2}>
971            {found.map(source =>
972              foldButton(`tab-${source}`, tabLabel(source), source === current ? { bold: true, underline: true } : { dimColor: true }, () =>
973                void update($, tab, () => source)
974              )
975            )}
976          </Box>
977        )}
978        {installed.length === 0 && <Text dimColor>Neither the reminders nor the handoff skill is installed.</Text>}
979        {installed.length > 0 && found.length === 0 && <Text dimColor>{`Hidden by the do-not-disturb profile ${view.on?.profile ?? ''}.`}</Text>}
980        {found.length > 0 && view.isFiltered && <Text dimColor>{`Do not disturb (${view.on?.profile ?? ''}): only some groups shown.`}</Text>}
981        <Box flexDirection="row" justifyContent="space-between">
982          <Text dimColor>
983            {trouble !== ''
984              ? `Could not read them: ${truncate(trouble, width - 48)}`
985              : loaded === null
986                ? 'Loading…'
987                : lastLoad > 0
988                  ? `live at ${clockTime(lastLoad)}`
989                  : ''}
990          </Text>
991          <Box flexDirection="row" gap={2}>
992            {shownTab !== null &&
993              foldAllControl('fold-all', isTabOpen, isTabOpen ? '▴ collapse all' : '▾ expand all', () => void setFolds(tabFolds, !isTabOpen, true))}
994            {busyKey === REFRESH ? (
995              <Text key="refresh" dimColor>
996                syncing…
997              </Text>
998            ) : (
999              <Button key="refresh" label="refresh" onPress={() => void refresh($)} />
1000            )}
1001          </Box>
1002        </Box>
1003        {shownTab !== null && (shownTab.drawn as never)}
1004      </Box>
1005    )
1006  })
1007}
1008
types/index.d.ts 86 lines
1/** The parts the pane shows, each when its skill is installed. */
2export type Source = 'reminders' | 'handoffs'
3
4/** One reminder as `reminders.sh json` prints it; fields with no value are left out. */
5export type Reminder = {
6  id: string
7  /** due, later, expired, elsewhere (another folder or computer) or past (closed). */
8  state: string
9  title: string
10  on?: string
11  until?: string
12  /** Repeating ones: the days, as written ("friday", "monday, thursday", "day", "weekday"). */
13  every?: string
14  /** Repeating ones: when it shows next, "YYYY-MM-DD HH:MM". */
15  next?: string
16  /** Repeating ones: the last day it was closed for. */
17  done?: string
18  where?: string
19  machine?: string
20  /** What it is filed under in the pane: an organization, a client, a project. */
21  group?: string
22  /** Past ones: when it was closed. */
23  closed?: string
24  body?: string
25  /** A task for this assistant, which runs it by itself; its group is the assistant's name. */
26  agent?: string
27}
28
29/** One handoff note as `handoff.sh json` prints it. */
30export type Handoff = {
31  id: string
32  /** open, done (picked up) or canceled. */
33  state: string
34  title: string
35  /** The computer that wrote it. */
36  from?: string
37  date?: string
38  repo?: string
39  branch?: string
40  closed?: string
41  /** Written on this computer. */
42  isMine?: boolean
43  body?: string
44}
45
46/**
47 * What the dnd mod has on, the part of it this pane reads: by part, hidden, or the names kept
48 * (`keep`: lowercase, the last part of a path).
49 */
50export type DndActive = {
51  profile: string
52  parts: Partial<Record<string, { mode?: 'show' | 'hide' | 'only'; keep?: string[] }>>
53}
54
55declare module 'claude-code' {
56  interface PluginState {
57    /** The dnd mod's, read only under its shape `dnd-active/1`: absent when it is not installed. */
58    dnd: {
59      active: Shaped<DndActive | null>
60    }
61    /** The configuration mod's low power mode, read under its shape `low-power/1`. */
62    configuration: {
63      power: Shaped<{ setting: string; isLow: boolean } | null>
64    }
65    'reminders-handoffs': {
66      reminders: Reminder[] | null
67      handoffs: Handoff[] | null
68      /** The parts whose skill is installed, in tab order. */
69      enabled: Source[]
70      /** By part: why its list could not be read, '' when it could. */
71      problems: Partial<Record<Source, string>>
72      tab: Source
73      /** Reminder and note ids whose details are unfolded. */
74      openIds: Record<string, boolean>
75      /** Sections and groups (`<section>|<group>`) the user folded or unfolded. */
76      openSections: Record<string, boolean>
77      /** `<part>|<action>|<id>` pressed once: a second press confirms. */
78      armed: string
79      /** `<part>|<action>|<id>` while it runs, `refresh` while the refresh button's sync runs. */
80      busy: string
81      /** When the lists were last read, in epoch milliseconds; 0 before the first read. */
82      loadedTime: number
83    }
84  }
85}
86