Brewmod reminders -- durable file-based reminders (check prod tomorrow 09:00) that survive sessions: band at session start, offered at the first prompt…

<!-- Stable deep link, shared outside the repo: #demo. Keep the id. --> <a id="demo"></a><img src="../assets/brewmods/reminders.gif" width="800" alt="Claude Code terminal: a reminder is created in one sentence and saved at once. In a later session the band shows it as fired, the first prompt gets a one-line heads-up from Claude, and **1 runs it and closes it.">
<h1 align="center">Reminders</h1>
<a href="https://github.com/kochetkov-ma/claude-brewcode/stargazers"><img src="https://badgen.net/github/stars/kochetkov-ma/claude-brewcode?icon=github&label=stars" alt="GitHub stars"></a> <img src="https://img.shields.io/badge/Claude_Code-2.1.301%2B-d97706" alt="Requires Claude Code 2.1.301+"> <img src="https://img.shields.io/badge/type-mod-8b5cf6" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT">
Part of the brewcode mods, next to brewmod-placeholders and brewmod-plan-effort.
CronCreate and /loop die with the session. A reminder here is a plain Markdown file in the project (.claude/scheduled/) or global (every project of your Claude profile). Say it in one sentence, it is saved at once, and any later session shows it when it fires. Nothing runs on its own.
Update Claude Code first: the mods need Claude Code 2.1.301 or newer (
claude --version, thenclaude updateand restart). The mods use the hooks module API, which is early access.
claude plugin marketplace add https://github.com/kochetkov-ma/claude-brewcode && claude plugin install brewmod-reminders@claude-brewcode
Then run /reload-plugins. Needs node on the PATH. Update with /brewtools:plugin-update.
Wed 16:10 "remind me tomorrow at 9 to check the prod logs and show me errors"
-> saved at once, kind: action
Thu 09:00 it fires. Session closed: nothing happens yet
Thu 09:20 you open a session -> band "reminders · 1 fired"
Thu 09:21 your first prompt -> Claude opens its reply with one line about it
Thu 09:22 **1 -> Claude runs it, closes it with a one-line result -> band gone
Next hour no repeat line; a newly fired reminder is announced at once
Every reminder needs a time or "someday"; if you give none, Claude asks once ("When should it fire?").
/remind someday, the and ? pickers).| Command | Result | |
|---|---|---|
/remind | Fired reminders, numbered like the band | |
** | Picker of fired and someday reminders, last row ?; Enter on a bare in the terminal prints the /remind all list (Remote Control and SDK prompts pass through to Claude) | |
**1 | Run the first fired reminder (1 2 runs several) | |
**? | Every reminder by state, tagged project or global; Enter in the terminal prints the /remind all list plus the 10 newest closed | |
<id> / project:<id> / **global:<id> | Run one by id; the scoped form picks the copy when the id exists in both folders | |
/remind all | Fired, Waiting and Someday | |
| `/remind snooze <N\ | id> <when>` | New time (+2h, tomorrow 9:00, fri 14:00) |
/remind help | Summary, folders, desktop alert state |
All commands, settings (prefix, cooldownMinutes, notify, git mode), file format (closed items deleted after 30 days; with git set to commit or commit-push, expired project files are kept and Claude is asked to delete and commit them) and tools: full docs.
Project page | Source | All mods | Release notes
Author: Maksim Kochetkov | License: MIT
hooks/register.ts 814 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2025-2026 Maksim Kochetkov
3
4import { atom, read, update } from 'claude-code'
5import type { Register } from 'claude-code'
6import {
7 CLAIMS_DIR, HANDOFF_MARK, RUN_MARK, bodyOf, buildItem, calendar, claimJson, claimLive, claimName, classify, closedAt, closedLabel, datePatch, editText, formatWhen, isItemName, isoZ, itemKey, itemName, matchId,
8 parseClaim, parseConfig, parseItem, parseWhenNatural, patchHeader, rangeOf, relative, reopenPatch, sections, sessionStateJson, sessionStateName, slugOf, slugify, systemTz, tagOf, validSid, windowOf,
9 type Claim, type Item, type State,
10} from './lib/items.mjs'
11import type { ReminderView } from '../types'
12
13// The band reads this view; the atom is declared in this file only (the scan reads it).
14const viewAtom = atom({ plugin: 'brewmod-reminders', key: 'view' } as const, { due: 0, rows: [] })
15
16const DEFAULT_PREFIX = '**'
17const PREFIX_RE = /^[!-/:-@[-^`{-~]{2,8}$/
18const PREFIX_FIX = '/config -> Reminder picker prefix'
19// Local copy of brewmod-placeholders validatePrefix (hooks/lib/match.ts); here a `/`-leading prefix is refused, not only warned.
20const validatePrefix = (raw: unknown): { prefix: string; warning: string | null } =>
21 raw === undefined
22 ? { prefix: DEFAULT_PREFIX, warning: null }
23 : typeof raw !== 'string' || !PREFIX_RE.test(raw)
24 ? { prefix: DEFAULT_PREFIX, warning: `reminder prefix must be 2-8 ASCII punctuation characters (not _), using ${DEFAULT_PREFIX} · change: ${PREFIX_FIX}` }
25 : raw.startsWith('/')
26 ? { prefix: DEFAULT_PREFIX, warning: `reminder prefix starting with / clashes with slash commands, using ${DEFAULT_PREFIX} · change: ${PREFIX_FIX}` }
27 : { prefix: raw, warning: null }
28const escRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
29type Tok = { start: number; end: number; name: string }
30// Standalone `<prefix><name>` tokens; `**1 thing**` is bold: skipped when the next prefix on its line follows a non-space (a closer).
31// An opener (` **x`) or no further prefix keeps it, so `**1 and **bold** x` expands **1.
32function tokensOf(text: string, prefix: string): Tok[] {
33 const re = new RegExp(`(^|\\s)${escRe(prefix)}((?:project:|global:)?[\\w-]+)(?=\\s|$)`, 'g')
34 return [...text.matchAll(re)].flatMap((m) => {
35 const start = (m.index ?? 0) + (m[1] ?? '').length
36 const end = start + prefix.length + (m[2] ?? '').length
37 const eol = text.indexOf('\n', end)
38 const at = text.slice(end, eol === -1 ? undefined : eol).indexOf(prefix)
39 return at !== -1 && !/\s/.test(text.charAt(end + at - 1)) ? [] : [{ start, end, name: m[2] ?? '' }]
40 })
41}
42// Origins whose text is the person's own typed prompt (same rule as brewmod-placeholders).
43const TYPED_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
44const TICK_MS = 60_000
45const BAND_ROWS = 3
46const HISTORY_SHOWN = 20
47const HISTORY_PICK = 10
48const TOOL = 'mcp__brewmod-reminders__'
49const NEEDS_TOOLS = 'Claude tools for reminders are unavailable on this build; edit the files in .claude/scheduled by hand (see /remind help).'
50
51type Entry = Item & { global: boolean; dir: string; path: string; claim: Claim | null; state: State }
52type Io = {
53 exists: (p: string) => Promise<boolean>
54 list: (p: string) => Promise<{ name: string; kind: string }[]>
55 read: (p: string) => Promise<string>
56 write: (p: string, t: string) => Promise<void>
57}
58type Dirs = { project: string; global: string | null; config: string }
59type Env = { cfg: string | undefined; home: string | undefined; override: string | undefined }
60type Out = { text: string; isError?: true }
61
62// override = REMIND_GLOBAL_DIR: the global folder itself (live tests; unset in normal use).
63const dirsOf = (root: string | undefined, { cfg, home, override }: Env): Dirs => ({
64 project: `${root ?? '.'}/.claude/scheduled`,
65 global: override !== undefined && override !== '' ? override : cfg !== undefined && cfg !== '' ? `${cfg}/reminders` : home !== undefined && home !== '' ? `${home}/.claude/reminders` : null,
66 config: `${root ?? '.'}/.claude/reminders.json`,
67})
68const fail = (text: string): Out => ({ text, isError: true })
69const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
70const validTz = (tz: string): boolean => {
71 try {
72 new Intl.DateTimeFormat('en', { timeZone: tz })
73 return tz !== ''
74 } catch {
75 return false
76 }
77}
78// env TZ may be POSIX-style (":Europe/Lisbon"); an unusable value falls back to the system zone, then UTC.
79const tzOf = (fromEnv: string | undefined): string => {
80 const tz = (fromEnv ?? '').replace(/^:/, '')
81 return validTz(tz) ? tz : validTz(systemTz()) ? systemTz() : 'UTC'
82}
83const oneLine = (s: string, max: number): string => s.replace(/\s+/g, ' ').trim().slice(0, max)
84
85async function loadDir(io: Io, dir: string, global: boolean, now: number): Promise<Entry[]> {
86 if (!(await io.exists(dir).catch(() => false))) return []
87 const files = (await io.list(dir).catch(() => [])).filter((f) => f.kind === 'file' && isItemName(f.name))
88 const rows = await Promise.all(
89 files.map(async (f): Promise<Entry | null> => {
90 const path = `${dir}/${f.name}`
91 const text = await io.read(path).catch(() => null)
92 if (text === null) return null
93 const item = parseItem(f.name, text)
94 const claim = parseClaim(await io.read(`${dir}/${CLAIMS_DIR}/${claimName(item.id)}`).catch(() => ''))
95 return { ...item, global, dir, path, claim, state: classify(item, claim, now) }
96 }),
97 )
98 return rows.filter((r): r is Entry => r !== null)
99}
100
101const loadAll = async (io: Io, dirs: Dirs, now: number): Promise<Entry[]> => [
102 ...(await loadDir(io, dirs.project, false, now)),
103 ...(dirs.global === null ? [] : await loadDir(io, dirs.global, true, now)),
104]
105
106const byDue = (a: Entry, b: Entry): number => (a.due ?? 0) - (b.due ?? 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
107const isNum = (s: string): boolean => /^\d+$/.test(s)
108const nameOf = (i: Item): string => (i.title === '' ? i.id : i.title)
109// A live claim of another session, else null.
110const foreignClaim = (e: Entry, sid: string, now: number): Claim | null => (e.claim !== null && claimLive(e.claim, now) && e.claim.session !== sid ? e.claim : null)
111const claimFile = (e: Entry): string => `${e.dir}/${CLAIMS_DIR}/${claimName(e.id)}`
112const whenLine = (i: Item, tz: string, now: number, bad: string = i.dueRaw): string => (i.due !== null ? formatWhen(i.due, tz, now) : i.someday ? 'no date (someday)' : bad)
113const MARK = { action: '▶', note: '✎' } as const
114const mark = (i: Item): string => MARK[i.kind]
115// N in /remind run 1 and **1 = position in this list, the same order as the band.
116const firedList = (items: Entry[]): Entry[] => items.filter((i) => i.state === 'due').sort(byDue)
117
118// The band lists due items only: pending (upcoming) and running (claimed by a live session) never show.
119function viewOf(items: Entry[], now: number): ReminderView {
120 const due = firedList(items)
121 return {
122 due: due.length,
123 rows: due.map((i) => `${mark(i)} ${nameOf(i)} ${relative(i.due ?? now, now)}${i.global ? ' (global)' : ''}`).slice(0, BAND_ROWS),
124 }
125}
126
127// every = all plus the newest HISTORY_PICK closed (Enter on a bare `<prefix>?`); no command word for it.
128type QState = 'active' | 'all' | 'someday' | 'history' | 'every'
129type Query = { state: QState; scope?: 'project' | 'global'; from: number; to: number }
130const STATE_WORDS = new Map<string, QState>([['active', 'active'], ['all', 'all'], ['someday', 'someday'], ['history', 'history'], ['done', 'history']])
131const query = (state: QState, rest: Partial<Query> = {}): Query => ({ state, from: -Infinity, to: Infinity, ...rest })
132
133// `show` words after the verb: [active|all|someday|history|done] [project|global] [window]; null when anything is left over (-> free text).
134function parseShow(words: string[], now: number, tz: string): Query | null {
135 const w = words.map((x) => x.toLowerCase())
136 const state = STATE_WORDS.get(w[0] ?? '')
137 const at = state === undefined ? 0 : 1
138 const scope = w[at] === 'project' || w[at] === 'global' ? w[at] : undefined
139 const rest = w.slice(at + (scope === undefined ? 0 : 1)).join(' ')
140 const win = rest === '' ? { from: -Infinity, to: Infinity } : windowOf(rest, now, tz)
141 return win === null ? null : query(state ?? 'active', { scope: scope as Query['scope'], ...win })
142}
143
144function listText(all: Entry[], now: number, tz: string, dirs: Dirs, q: Query): string {
145 const windowed = q.from > -Infinity || q.to < Infinity
146 const inScope = (i: Entry): boolean => q.scope === undefined || i.global === (q.scope === 'global')
147 const items = all.filter((i) => {
148 const t = q.state === 'history' ? closedAt(i) : i.due
149 const firedAlways = i.state === 'due' && (q.state === 'all' || q.state === 'active')
150 return inScope(i) && (!windowed || firedAlways || (t !== null && t >= q.from && t < q.to))
151 })
152 const num = new Map(firedList(all).map((i, k) => [i.path, k + 1]))
153 const whenOf = (i: Entry): string => whenLine(i, tz, now, `bad due "${i.dueRaw}" (UTC with Z needed)`)
154 const running = (i: Entry): string => (i.state === 'running' ? ` | running in session ${i.claim?.session ?? '?'} since ${i.claim === null ? '?' : formatWhen(i.claim.at, tz, now)}` : '')
155 const row = (i: Entry): string => `- ${num.has(i.path) ? `${num.get(i.path)} ` : ''}${mark(i)} ${tagOf(i)} | ${whenOf(i)} | ${i.title}${running(i)}`
156 const histRow = (i: Entry): string => {
157 const ms = closedAt(i)
158 return `- ${i.status === 'cancelled' ? '✗' : '✓'} ${mark(i)} ${tagOf(i)} | ${closedLabel(i)} ${ms === null ? '?' : formatWhen(ms, tz, now)} | ${i.title}${i.result === '' ? '' : ` | ${i.result}`}`
159 }
160 const part = (label: string, rows: Entry[], f: (i: Entry) => string = row, n = rows.length): string[] => (rows.length === 0 ? [] : [`${label} (${n}):`, ...rows.map(f)])
161 const s = sections(items.map((item) => ({ item, state: item.state })))
162 const fired = s.fired.map((r) => r.item)
163 const waiting = [...s.running, ...s.waiting].map((r) => r.item).sort(byDue)
164 const someday = s.someday.map((r) => r.item)
165 const closed = s.history.map((r) => r.item)
166 const history = closed.slice(0, HISTORY_SHOWN)
167 const bad = items.filter((i) => i.state === 'unparseable').sort(byDue)
168 const sd = all.filter((i) => i.state === 'someday' && inScope(i)).length
169 const open = [...part('Fired', fired), ...part('Waiting', waiting), ...part('Someday', someday), ...part('Unreadable', bad)]
170 const more = closed.length - HISTORY_PICK
171 const out =
172 q.state === 'history'
173 ? part(`History (last ${HISTORY_SHOWN})`, history, histRow)
174 : q.state === 'someday'
175 ? part('Someday', someday)
176 : q.state === 'active'
177 ? [...part('Fired', fired), ...(fired.length > 0 && sd > 0 ? [`${sd} someday · /remind all`] : [])]
178 : q.state === 'every'
179 ? [...open, ...part('History', closed.slice(0, HISTORY_PICK), histRow, closed.length), ...(more > 0 ? [`+${more} more · /remind history`] : [])]
180 : open
181 const where = `project ${dirs.project} | global ${dirs.global ?? 'unavailable'}`
182 const none =
183 q.state === 'active'
184 ? `No fired reminders${sd > 0 ? ` (${sd} someday)` : ''}; /remind all lists the waiting ones.`
185 : q.state === 'history'
186 ? 'No closed reminders yet.'
187 : q.state === 'someday'
188 ? 'No someday reminders.'
189 : q.state === 'every'
190 ? 'No open or closed reminders.'
191 : 'No open reminders.'
192 return out.length === 0 ? `${none} ${where}` : [`Now: ${formatWhen(now, tz, now).replace(/, now$/, '')}`, ...out, where].join('\n')
193}
194
195const gitNote = (mode: string, i: { global: boolean; path: string }): string =>
196 i.global || mode === 'off' ? '' : `\nGit: commit only ${i.path}${mode === 'commit-push' ? ' and push' : ' (no push)'} following the repo's conventions; stage nothing else.`
197
198const isClosed = (e: Entry): boolean => e.state === 'done' || e.state === 'cancelled'
199// One item by id/slug/prefix within the scope filter; a string is the refusal text.
200// Same id in both folders (e.g. after a scope move): the one open copy wins (wantClosed: the one closed copy).
201function pick(items: Entry[], id: string, scope: string | undefined, wantClosed = false): Entry | string {
202 const scopes = scope === 'project' ? [false] : scope === 'global' ? [true] : [false, true]
203 const hits = scopes.map((g) => ({ g, m: matchId(items.filter((i) => i.global === g).map((i) => i.id), id) }))
204 const found = hits.flatMap((h) => ('id' in h.m ? [{ g: h.g, id: h.m.id }] : []))
205 const q = id.trim().toLowerCase().replace(/\.md$/, '')
206 const exact = found.filter((f) => f.id.toLowerCase() === q || slugOf(f.id) === q)
207 const hit = (found.length > 1 && exact.length === 1 ? exact : found).map((h) => items.find((i) => i.global === h.g && i.id === h.id) as Entry)
208 const fit = hit.length > 1 ? hit.filter((x) => isClosed(x) === wantClosed) : hit
209 if (hit.length > 1 && fit.length !== 1) return `"${id}" exists in project and global: pass scope`
210 const [one] = fit
211 if (one !== undefined) return one
212 const bad = hits.flatMap((h) => ('candidates' in h.m ? h.m.candidates : []))
213 return bad.length > 0 ? `"${id}" is ambiguous: ${bad.join(', ')}` : `no reminder matches "${id}"; /remind all lists the ids`
214}
215
216// A number = position in the fired list (same as the band); anything else = pick by id.
217function resolve(items: Entry[], token: string, scope: string | undefined): Entry | string {
218 if (!isNum(token)) return pick(items, token, scope)
219 return firedList(items)[Number(token) - 1] ?? `no fired reminder ${token}; /remind shows the numbered list`
220}
221const RUNNABLE: ReadonlySet<string> = new Set(['due', 'pending', 'running', 'someday'])
222// A `<prefix>[project:|global:]<name>` token's reminder via the /remind run resolver; closed, unreadable, ambiguous or unknown -> null (left as text).
223// A scoped number (`global:1`) stays text: N counts the fired list of both folders, the picker never writes one.
224const runnable = (items: Entry[], name: string): Entry | null => {
225 const [, scope, id = name] = /^(project|global):(.+)$/.exec(name) ?? []
226 if (scope !== undefined && isNum(id)) return null
227 const e = resolve(items, id, scope)
228 return typeof e === 'string' || !RUNNABLE.has(e.state) ? null : e
229}
230
231async function claimTo(io: Io, e: Entry, sid: string, now: number): Promise<Out | null> {
232 const file = claimFile(e)
233 if (!(await io.exists(`${e.dir}/${CLAIMS_DIR}/.gitignore`).catch(() => false))) await io.write(`${e.dir}/${CLAIMS_DIR}/.gitignore`, '*\n')
234 await io.write(file, claimJson(sid, now))
235 const back = parseClaim(await io.read(file).catch(() => ''))
236 return back !== null && back.session === sid ? null : fail(`${e.id} is running in session ${back?.session ?? '?'}; do not run it, tell the user`)
237}
238
239type Act = { id: string; action: string; until?: string; result?: string; scope?: string; fromScope?: string; text?: string; kind?: string; when?: string }
240type Ctx = { sid: string; now: number; tz: string; by: 'model' | 'user'; git: string }
241const ACTIONS = ['start', 'done', 'snooze', 'cancel', 'edit', 'reopen']
242const SOMEDAY_RE = /^(none|someday)$/i
243
244// edit: text/kind/when/scope in one write; a scope move writes the new file, then cancels the old one (a cancelled same-name file at the target is overwritten).
245// Returns old and new lines, a one-line undo call and the old file.
246async function edit(io: Io, dirs: Dirs, e: Entry, req: Act, c: Ctx): Promise<Out> {
247 const { now, tz } = c
248 if (req.kind !== undefined && req.kind !== 'action' && req.kind !== 'note') return fail('kind is action or note')
249 const when = (req.when ?? '').trim()
250 let dueMs: number | null | undefined
251 if (when !== '') {
252 const w = SOMEDAY_RE.test(when) ? null : parseWhenNatural(when, now, tz)
253 if (w !== null && !w.ok) return fail(w.error)
254 dueMs = w === null ? null : w.ms
255 }
256 const body = req.text?.trim()
257 if (body === '') return fail('text must not be empty')
258 const here = e.global ? 'global' : 'project'
259 const moveTo = req.scope !== undefined && req.scope !== here ? req.scope : undefined
260 if (req.kind === undefined && dueMs === undefined && body === undefined && moveTo === undefined) return fail('nothing to change: pass text, kind, when or scope')
261 const old = await io.read(e.path)
262 const next = editText(old, { text: body, kind: req.kind, dueMs })
263 if (moveTo === undefined) await io.write(e.path, next)
264 else {
265 const dir = moveTo === 'global' ? dirs.global : dirs.project
266 if (dir === null) return fail('the global reminders folder is unavailable (no home directory)')
267 const dest = `${dir}/${e.name}`
268 const there = (await io.exists(dest)) ? parseItem(e.name, await io.read(dest)) : null
269 if (there !== null && there.status !== 'cancelled') return fail(`${e.name} already exists in ${moveTo}; edit it there`)
270 await io.write(dest, next)
271 await io.write(e.path, patchHeader(old, { status: 'cancelled', cancelled: isoZ(now) }))
272 if (e.claim !== null) await io.write(claimFile(e), '')
273 }
274 const after = parseItem(e.name, next)
275 const change = (a: string, b: string): string => (a === b ? a : `${a} → ${b}`)
276 const head = `Reminder updated ${change(`${mark(e)} ${e.kind}`, `${mark(after)} ${after.kind}`)} · ${change(here, moveTo ?? here)} · ${e.id}`
277 // The project-side file this edit wrote: the moved-in copy, the cancelled source, or the edited file.
278 const projectFile = moveTo === 'project' ? `${dirs.project}/${e.name}` : e.path
279 const git = gitNote(c.git, { global: moveTo === undefined && e.global, path: projectFile })
280 const oldWhen = e.someday ? 'someday' : e.due !== null && e.due > now ? isoZ(e.due) : null
281 const undo = {
282 id: e.id,
283 action: 'edit',
284 ...(body !== undefined && body !== bodyOf(old) ? { text: bodyOf(old) } : {}),
285 ...(req.kind !== undefined && req.kind !== e.kind ? { kind: e.kind } : {}),
286 ...(dueMs !== undefined && oldWhen !== null ? { when: oldWhen } : {}),
287 ...(moveTo === undefined ? {} : { scope: here }),
288 from_scope: moveTo ?? here,
289 }
290 const lost = dueMs !== undefined && oldWhen === null ? ` (the old time ${e.dueRaw} has passed or is unreadable: ask for a new one)` : ''
291 const undoLine = Object.keys(undo).length > 3 || lost !== '' ? `Undo, when the user asks: update_reminder ${JSON.stringify(undo)}${lost}` : 'Undo: nothing changed'
292 return {
293 text: `${head}\n- ${mark(e)} ${here} | ${whenLine(e, tz, now)} ${e.title}\n+ ${mark(after)} ${moveTo ?? here} | ${whenLine(after, tz, now)} ${after.title}${git}\n${undoLine}\nOld file (${e.path}):\n${old.trimEnd()}`,
294 }
295}
296
297// start | done | snooze | cancel | edit | reopen; the model is refused on a live foreign claim, the person (by: 'user') is not.
298async function act(io: Io, dirs: Dirs, a: { req: Act } & Ctx): Promise<Out> {
299 const { req, sid, now, tz } = a
300 if (!ACTIONS.includes(req.action)) return fail(`unknown action "${req.action}": start, done, snooze, cancel, edit or reopen`)
301 if ([req.scope, req.fromScope].some((s) => s !== undefined && s !== 'project' && s !== 'global')) return fail('scope is project or global')
302 const e = pick(await loadAll(io, dirs, now), req.id, req.action === 'edit' ? req.fromScope : (req.fromScope ?? req.scope), req.action === 'reopen')
303 if (typeof e === 'string') return fail(e)
304 const closed = isClosed(e)
305 if (req.action === 'reopen' ? !closed : closed) return fail(req.action === 'reopen' ? `${e.id} is not closed` : `${e.id} is already ${e.state}`)
306 if (e.state === 'ignored') return fail(`${e.id} has no status header; add \`status: pending\` and \`due:\` first`)
307 const foreign = foreignClaim(e, sid, now)
308 if (foreign !== null && a.by === 'model') return fail(`${e.id} is running in session ${foreign.session} since ${formatWhen(foreign.at, tz, now)}; do not run it or change it, tell the user`)
309 if (req.action === 'start') {
310 if (e.state === 'unparseable') return fail(`${e.id} has an unreadable due line; fix the header first`)
311 const lost = await claimTo(io, e, sid, now)
312 return lost ?? { text: `started ${tagOf(e)}; carry out the text below, then update_reminder {id, action:"done", result}.\n${await io.read(e.path)}` }
313 }
314 if (req.action === 'edit') return edit(io, dirs, e, req, a)
315 const text = await io.read(e.path)
316 let patch: Record<string, string | null>
317 if (req.action === 'done') {
318 const result = oneLine(req.result ?? '', 400)
319 patch = { status: 'done', done: isoZ(now), ...(result === '' ? {} : { result }) }
320 } else if (req.action === 'cancel') patch = { status: 'cancelled', cancelled: isoZ(now) }
321 else {
322 const blank = (req.until ?? '').trim() === ''
323 if (req.action === 'reopen' && blank) patch = reopenPatch()
324 else {
325 const w = parseWhenNatural(req.until ?? '', now, tz)
326 if (!w.ok) return fail(w.error)
327 patch = req.action === 'reopen' ? reopenPatch(w.ms) : datePatch(w.ms)
328 }
329 }
330 await io.write(e.path, patchHeader(text, patch))
331 if (e.claim !== null) await io.write(claimFile(e), '')
332 const label = nameOf(e)
333 const dueTxt = (patch.due ?? e.dueRaw) === 'none' ? 'someday (no date)' : `due ${patch.due ?? e.dueRaw}`
334 const said =
335 req.action === 'snooze' ? `snoozed: ${label} (${tagOf(e)}) until ${patch.due}` : req.action === 'reopen' ? `reopened: ${label} (${tagOf(e)}) ${dueTxt}` : `${req.action === 'done' ? 'done' : 'cancelled'}: ${label} (${tagOf(e)})`
336 return { text: `${said}${gitNote(a.git, e)}` }
337}
338
339type Add = { scope?: string; when?: string; text?: string; slug?: string; tz?: string; kind?: string }
340
341// Saves at once: no preview, no confirm. when `someday`/`none` = no date (never fires).
342async function add(io: Io, dirs: Dirs, a: Add, now: number, localTz: string, git: string): Promise<Out> {
343 if (a.scope !== 'project' && a.scope !== 'global') return fail('scope is required: project or global')
344 const dir = a.scope === 'project' ? dirs.project : dirs.global
345 if (dir === null) return fail('the global reminders folder is unavailable (no home directory)')
346 const text = (a.text ?? '').trim()
347 if (text === '') return fail('text is required')
348 const raw = (a.when ?? '').trim()
349 const kind = a.kind === 'action' || a.kind === 'note' ? a.kind : null
350 if (raw === '' || kind === null) return fail([...(raw === '' ? [`when is required: nothing is saved without a trigger time. ${calendar(now, localTz)} ${WHEN_RULE}`] : []), ...(kind === null ? [KIND_RULE] : [])].join(' '))
351 const tz = a.tz !== undefined && a.tz !== '' ? a.tz : localTz
352 if (!validTz(tz)) return fail(`unknown time zone "${tz}"`)
353 const w = SOMEDAY_RE.test(raw) ? null : parseWhenNatural(raw, now, tz)
354 if (w !== null && !w.ok) return fail(w.error)
355 const dueMs = w === null ? null : w.ms
356 const slug = slugify(a.slug !== undefined && a.slug !== '' ? a.slug : text)
357 let name = itemName(dueMs, slug)
358 for (let n = 2; n <= 99 && (await io.exists(`${dir}/${name}`)); n++) name = itemName(dueMs, `${slug.slice(0, 40 - `-${n}`.length)}-${n}`)
359 if (await io.exists(`${dir}/${name}`)) return fail(`no free file name for "${slug}" at that time; use another slug`)
360 const id = name.replace(/\.md$/, '')
361 const path = `${dir}/${name}`
362 await io.write(path, buildItem({ dueMs, text, now, kind }))
363 const due = dueMs === null ? 'someday (no date)' : `due ${formatWhen(dueMs, tz, now)}`
364 return { text: `saved ${id} (${a.scope}) | ${MARK[kind]} ${kind} | ${due} | ${path}\nchange it by saying so · /remind cancel ${id}${gitNote(git, { global: a.scope === 'global', path })}` }
365}
366
367const WHEN_RULE =
368 'If the request names no time, ask ONE AskUserQuestion "When should it fire?" with 2-3 options you derive from this reminder\'s own context (e.g. post-deploy prod check: tonight 23:00 / tomorrow 09:00 / in 1 week; renew a domain: in 2 weeks / in 1 month; check nightly-run logs: tonight / tomorrow morning), each label showing its concrete local time, the best fit marked "(Recommended)", plus a last option "No date (someday)" (answer -> when:"someday": it never fires and shows only when the user asks); free text stays available through Other; no fixed list. A kind (action or note) or scope (project or global) that is still unclear joins that same AskUserQuestion as extra questions; ask nothing else. Every option is a concrete local time: a day-level answer like "in 1 week" or "next Monday" is saved at the time its label showed (default 09:00 local), e.g. when:"2026-10-28 09:00" or "mon 09:00"; never a bare date.'
369const KIND_RULE =
370 'kind is required: action (Claude does the work when the user runs it, e.g. "check the prod logs") or note (only tells the user, closes once shown, e.g. "reply to Ana"). Infer it from the request; ask only when it is unclear, as an extra question in the same single AskUserQuestion as the time.'
371
372const HELP = (dirs: Dirs, hasTools: boolean, alert: string, p: string): string =>
373 [
374 '/remind = the fired reminders, numbered like the band (no Claude turn)',
375 `${p} / ${p}? typeahead: fired + someday / every reminder by state, project and global; ${p}N or ${p}<id> + Enter runs one; a bare ${p} + Enter prints /remind all, ${p}? also the 10 newest closed (no Claude turn)`,
376 '/remind all [project|global] [today|tomorrow|week|+Nd|+Nw|until YYYY-MM-DD] Fired, Waiting and Someday; the window filters by trigger time (week = until Sunday 23:59, local)',
377 '/remind history [filters] = closed: done, shown notes, cancelled | /remind someday = only the undated ones (show active|all|done and list still work)',
378 '/remind run <N|id> [words] Claude runs it (action) or shows it and closes it (note); N = number in /remind',
379 '/remind done|cancel [project|global] <N|id> close without running (id = file name, slug or unique prefix; no Claude turn)',
380 '/remind snooze [project|global] <N|id> <when> +2h | +1d | tomorrow 9:00 | today 17:30 | fri 14:00 (next Friday) | 2026-10-22 09:00 (your local zone; a day without a time = 09:00; gives a someday item a date; no Claude turn)',
381 '/remind reopen <id> [when] bring a closed one back, with a new time if given | /remind edit <N|id> <change> Claude changes text, kind, time or scope and shows old and new',
382 '/remind <words> add or ask in your words: /remind check prod tomorrow 9:00',
383 '/remind project|global <words> same with the folder fixed (default project)',
384 `Items: ${dirs.project}/\`<YYYYMMDD-HHMM>_<slug>.md\` or \`someday_<slug>.md\` (project), ${dirs.global ?? '~/.claude/reminders'}/ (global); header due (UTC, Z, or none), status, kind (action|note), body = the task.`,
385 'A fired item is offered at your first prompt of a session, then at most once per cooldown (a newly fired one at once); nothing runs unasked.',
386 `Desktop alert: ${alert}`,
387 ...(hasTools ? [] : [NEEDS_TOOLS]),
388 ].join('\n')
389
390const stamp = (now: number, tz: string): string => `Now: ${formatWhen(now, tz, now).replace(/, now$/, '')}, time zone ${tz}, UTC ${isoZ(now)}. Times I give without a zone are in ${tz}.`
391
392const handoff = (request: string, scope: string | null, now: number, tz: string): string =>
393 [
394 `${HANDOFF_MARK}${request}</request>`,
395 stamp(now, tz),
396 calendar(now, tz),
397 `Scope: ${scope ?? 'not given: project unless I say global, everywhere or in every project; ask only when unclear, in the same single dialog'}.`,
398 `Use the ${TOOL}* tools. To add: add_reminder saves at once (no preview, no confirm) when time, kind and scope are clear; then show me its confirmation line. Every reminder needs a trigger time and a kind (action or note; infer it from my words). ${WHEN_RULE} To look: list_reminders (state active|all|someday|done, from/to); to change: update_reminder (start|done|snooze|cancel|edit|reopen).`,
399 ].join('\n')
400
401// The text a run sends to Claude (/remind run, **N): action -> start, do, done; note -> show, say it was just a reminder, done.
402const runPrompt = (e: Entry, body: string, extra: string): string =>
403 [
404 `${RUN_MARK}${e.id} (${e.title}, ${e.kind}): ${body}`,
405 e.kind === 'note'
406 ? `This is a note: show me its full text above, say it was just a reminder (nothing to execute), then call update_reminder {id:"${e.id}", action:"done"}.`
407 : `This is an action: call update_reminder {id:"${e.id}", action:"start"}, carry out its text above, then update_reminder {id:"${e.id}", action:"done", result:<one line>}.`,
408 ...(e.global ? ['It is global: pass scope:"global" in every update_reminder call.'] : []),
409 ...(extra === '' ? [] : [`My extra words: ${extra}`]),
410 ].join('\n')
411
412const editHandoff = (e: Entry, change: string, now: number, tz: string): string =>
413 [
414 `${HANDOFF_MARK}edit ${e.id}: ${change}</request>`,
415 stamp(now, tz),
416 `Edit reminder ${e.id} (${e.title}, ${e.kind}, ${e.global ? 'global' : 'project'}): ${change}`,
417 `Call ${TOOL}update_reminder {id:"${e.id}", action:"edit"} with only what changes: text, kind (action|note), when (a time, or "someday" for no date), scope (the folder to move it to; from_scope:"${e.global ? 'global' : 'project'}" is where it is now). Show me the old and new lines it returns.`,
418 ].join('\n')
419
420const TOOLS = [
421 {
422 name: 'list_reminders',
423 description:
424 "List the user's durable reminders: files in this project's .claude/scheduled and the global ~/.claude/reminders, with ids and times in the user's zone. Fired ones are numbered like the band. state: active = fired (due) only, with an 'N someday' count line; all (default) = Fired, Waiting and Someday sections by trigger time; someday = the undated ones only (never fire); done = closed: done, shown notes and cancelled. For 'anything to remind me?', 'what's pending?', 'any reminders?', 'есть ли чем мне напомнить?' call state active and show the fired ones, then mention the someday count and offer them. from/to filter by trigger time, so 'reminders that fire this week in this project' = scope project, from 'week' (or from 'week' alone: now until Sunday 23:59). Call it before answering a question about reminders or when an id for update_reminder is unclear.",
425 inputSchema: {
426 type: 'object',
427 properties: {
428 scope: { type: 'string', enum: ['project', 'global'], description: 'omit for both' },
429 state: { type: 'string', enum: ['active', 'all', 'someday', 'done'], description: 'default all' },
430 from: { type: 'string', description: 'earliest trigger: ISO, "YYYY-MM-DD" (00:00 local) or today|tomorrow|week|+Nd|+Nw|until YYYY-MM-DD (the window start)' },
431 to: { type: 'string', description: 'latest trigger, exclusive: ISO, "YYYY-MM-DD" (through that day) or a window word (the window end)' },
432 },
433 },
434 },
435 {
436 name: 'add_reminder',
437 description: `Add a durable reminder: a file that survives sessions and is offered at the first prompt of a later session once due. Unlike CronCreate or /loop (session-only, lost when the session ends), use it for EVERY 'remind me ...' request, short ones too ('in 2 minutes', 'tomorrow 9:00 check prod'); CronCreate only for repeating in-session polling. It saves at once (no preview, no confirm) and returns a confirmation line with the id; show it to the user. Every reminder MUST have a trigger time (or the explicit 'someday' = no date, never fires) and a kind. When the request names no time, FIRST call this tool without when: it saves nothing and returns today's date with the weekdays of the next days and the ask rule; then ask. ${WHEN_RULE} kind: action = Claude does the work when the user runs it (check the logs, run a migration dry run); note = only tells the user, closes once shown (build the CSV export, reply to Ana); infer it from context and ask only when unclear, in the same single AskUserQuestion as the time. scope: project (this repo) unless the user says global, everywhere or in every project; ask only when unclear, in that same question. text: what to do when due, in the user's words, self-contained. slug: 2-5 lowercase ASCII words. when: ISO with offset or Z, local 'YYYY-MM-DD HH:MM' in the user's zone (or tz), 'YYYY-MM-DD' (09:00 local), 'today|tomorrow [HH:MM]' (default 09:00), a weekday 'fri 14:00' (next such day, default 09:00), +<n>m|h|d|w, or 'someday'; a past time is rejected. Take the date and zone from the Now line of the helper text or the current date.`,
438 inputSchema: {
439 type: 'object',
440 properties: {
441 scope: { type: 'string', enum: ['project', 'global'], description: 'project (this repo, default) or global (all projects)' },
442 kind: { type: 'string', enum: ['action', 'note'], description: 'action = Claude does it when run; note = just a reminder, closes once shown' },
443 when: { type: 'string', description: 'ISO with offset/Z, "YYYY-MM-DD HH:MM" local, "YYYY-MM-DD" (09:00 local), "tomorrow 9:00", "fri 14:00" (next Friday), +2h, +1w or "someday" (no date); needed to save: when the user named none, call without it first to get the calendar, then ask' },
444 text: { type: 'string', description: 'what to do when it is due' },
445 slug: { type: 'string', description: 'short ascii file name part' },
446 tz: { type: 'string', description: 'IANA zone for a local time; default the user zone' },
447 },
448 required: ['scope', 'kind', 'text', 'slug'],
449 },
450 },
451 {
452 name: 'update_reminder',
453 description:
454 'Change a reminder by id (file name, slug or unique prefix). Never run a due reminder unasked: offer it, then act on the answer. start: claim it before running its text (refused while another live session runs it; returns the text). done: after running (or after showing a note), result = one-line outcome. snooze: until = +2h, "tomorrow 9:00", a weekday "fri 14:00" (next such day, default 09:00), "YYYY-MM-DD HH:MM" or ISO; gives a someday item a date. cancel: drop it. reopen: bring a closed one back, until = optional new time. edit: change any of text, kind (action|note), when (a time in the snooze forms, or "someday" for no date), scope (the folder to MOVE it to; from_scope names where it is now); returns the old and new lines, show them, plus an Undo call and the old file: when the user says undo, make that call. scope (other actions) or from_scope only when the id exists in both project and global.',
455 inputSchema: {
456 type: 'object',
457 properties: {
458 id: { type: 'string', description: 'reminder id from list_reminders or the offer' },
459 action: { type: 'string', enum: ['start', 'done', 'snooze', 'cancel', 'edit', 'reopen'] },
460 until: { type: 'string', description: 'snooze: the new time (+2h, "tomorrow 9:00", "fri 14:00", "YYYY-MM-DD HH:MM", ISO); reopen: optional new time' },
461 result: { type: 'string', description: 'done only: one-line outcome' },
462 text: { type: 'string', description: 'edit only: the new body' },
463 kind: { type: 'string', enum: ['action', 'note'], description: 'edit only' },
464 when: { type: 'string', description: 'edit only: a time (+2h, "tomorrow 9:00", "fri 14:00", "YYYY-MM-DD HH:MM", ISO), or "someday" for no date' },
465 scope: { type: 'string', enum: ['project', 'global'], description: 'edit: the folder to move it to; other actions: only when the id exists in both' },
466 from_scope: { type: 'string', enum: ['project', 'global'], description: 'only when the id exists in both (edit: where it is now)' },
467 },
468 required: ['id', 'action'],
469 },
470 },
471] as const
472
473// No guards around the mod's own logic: the engine skips a failing hook and the chain goes on; `$` is never passed on.
474export const register: Register = (on, options) => {
475 const check = validatePrefix(options.prefix)
476 const prefix = check.prefix
477 const notifyOn = options.notify !== false // userConfig `notify`, default on
478 let lastNotify = '' // the last $.ui.notify result, one line for /remind help
479 let hintTail = ''
480 let known: Entry[] = [] // items of the last load: the highlight resolves against them (prompt.edit does no I/O)
481 let hasTools = false
482 // Settles with hasTools once session.start's registration ends: a /remind typed right after launch waits for it.
483 let toolsSettled: (v: boolean) => void = () => undefined
484 const toolsReady = new Promise<boolean>((resolve) => (toolsSettled = resolve))
485 // The band/toast refresh of this session, set at session.start; `loaded` = items a hook just read (one numbering source).
486 let refresh: ((loaded?: { items: Entry[]; t: number }) => Promise<void>) | null = null
487 let shownKey = ''
488 let seenDue: Set<string> | null = null
489 let io: Io // set at session.start; `$` is never passed on, so the hooks share these closures
490 let env: Env // static env; the project root is read per call (/cd, worktree)
491 let interactive = false
492 let stateSid = ''
493
494 on('session.start', async ($, e, next) => {
495 io = { exists: (p) => $.fs.exists(p), list: (p) => $.fs.list(p), read: (p) => $.fs.read(p), write: (p, t) => $.fs.write(p, t) }
496 env = { cfg: await $.env.get('CLAUDE_CONFIG_DIR'), home: (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')), override: await $.env.get('REMIND_GLOBAL_DIR') }
497 if (check.warning !== null) $.ui.toast(check.warning)
498 const now = await $.clock.now()
499 const sid = await $.session.id()
500 // remind.mjs reads this file to tell a typed prompt from a headless one when the hook payload lacks `source`.
501 interactive = e.isInteractive
502 if (validSid(sid)) {
503 stateSid = sid
504 await $.fs.write(`${$.plugin.root}/.state/${sessionStateName(sid)}`, sessionStateJson(e.isInteractive, now)).catch(() => undefined)
505 }
506 await $.command
507 .register({ name: 'remind', description: 'Show fired reminders, run, snooze, close or edit them, or say in any words what to remind you of', argumentHint: '[all | history | run|done|cancel|snooze|reopen|edit <N|id> | help | any words]' })
508 .catch(() => undefined)
509 try {
510 for (const t of TOOLS) await $.tool.register({ name: t.name, description: t.description, inputSchema: t.inputSchema, isDeferred: false })
511 hasTools = true
512 } catch {
513 // older build: /remind still lists and edits, only the hand-off needs the tools
514 }
515 toolsSettled(hasTools)
516 if (e.isInteractive) {
517 refresh = async (loaded) => {
518 const t = loaded?.t ?? (await $.clock.now())
519 const items = loaded?.items ?? (await loadAll(io, dirsOf(await $.session.root().catch(() => undefined), env), t))
520 known = items
521 const view = viewOf(items, t)
522 const key = JSON.stringify(view)
523 if (key !== shownKey) {
524 shownKey = key
525 await update($, viewAtom, () => view)
526 }
527 const due = items.filter((i) => i.state === 'due')
528 if (seenDue === null) seenDue = new Set(due.map(itemKey))
529 else {
530 const fresh = due.filter((i) => !seenDue?.has(itemKey(i)))
531 for (const i of fresh) seenDue.add(itemKey(i))
532 if (fresh.length > 0) {
533 const what = fresh.length === 1 ? `reminder fired: ${fresh[0]?.title || fresh[0]?.id}` : `${fresh.length} reminders fired`
534 $.ui.toast(`${what} · ${prefix} to pick`, { timeoutMs: 8000 })
535 if (notifyOn) {
536 const r = await $.ui.notify(what, { title: 'Reminders' })
537 lastNotify = r.isSent ? `sent via ${r.channel}${r.channel === 'terminal_bell' ? ' (bell only, no text)' : ''}` : `not sent (${r.reason})`
538 }
539 }
540 }
541 }
542 await refresh().catch(() => undefined)
543 $.clock.every(TICK_MS, () => {
544 void refresh?.().catch(() => undefined)
545 })
546 }
547 return next(e)
548 })
549
550 // /clear fires no session.start: refresh the state file under the new session id for remind.mjs.
551 on('turn.complete', async ($, e, next) => {
552 const sid = await $.session.id()
553 if (validSid(sid) && sid !== stateSid) {
554 stateSid = sid
555 await $.fs.write(`${$.plugin.root}/.state/${sessionStateName(sid)}`, sessionStateJson(interactive, await $.clock.now())).catch(() => undefined)
556 }
557 return next(e)
558 })
559
560 // Picker: only a standalone `<prefix>` / `<prefix>?` / `<prefix><digits|name>` token (line start or after whitespace).
561 // `<prefix>`: fired, someday, then the `<prefix>?` hint; `<prefix>?`: every reminder by state; non-runnable rows insert the token itself.
562 on('prompt.autocomplete', { token: /^[^\w\s]{2}/ }, async ($, e, next) => {
563 const r = await next(e)
564 const want = e.token.startsWith(prefix) ? e.token.slice(prefix.length) : null
565 const every = want === '?'
566 if (want === null || !(every || /^[\w-]*$/.test(want)) || !(e.start === 0 || /\s/.test(e.text.charAt(e.start - 1)))) return r
567 const now = await $.clock.now()
568 const dirs = dirsOf(await $.session.root().catch(() => undefined), env)
569 const all = await loadAll(io, dirs, now)
570 known = all
571 await refresh?.({ items: all, t: now }).catch(() => undefined)
572 const hit = (i: Entry, n?: number): boolean => (isNum(want) ? n !== undefined && String(n).startsWith(want) : want === '' || every || `${i.id} ${i.title}`.toLowerCase().includes(want.toLowerCase()))
573 const s = sections(all.map((item) => ({ item, state: item.state })))
574 const where = (i: Entry): string => (i.global ? 'global' : 'project')
575 // Same id open in both folders: the row token names its folder so the submit resolver picks that copy.
576 const twin = (i: Entry): boolean => all.some((o) => o.id === i.id && o.global !== i.global && !isClosed(o))
577 const fired = s.fired.flatMap((x, k) => (hit(x.item, k + 1) ? [{ i: x.item, n: k + 1 }] : []))
578 const someday = isNum(want) ? [] : s.someday.map((x) => x.item).filter((i) => hit(i))
579 const firedRows = fired.map(({ i, n }) => ({ text: `${prefix}${n}`, label: `${prefix}${n} ${mark(i)} ${nameOf(i)}`, description: `${relative(i.due ?? now, now)} · ${where(i)}` }))
580 const openRow = (i: Entry, when: string) => ({ text: `${prefix}${twin(i) ? `${where(i)}:` : ''}${i.id}`, label: `${mark(i)} ${nameOf(i)}`, description: `${when} · ${where(i)}` })
581 const somedayRows = someday.map((i) => openRow(i, 'someday'))
582 const none = { text: e.token, label: 'no reminders · ask Claude "remind me ..."' }
583 const ignored = all.filter((i) => i.state === 'ignored').length
584 if (every) {
585 const div = (name: string, n: number) => ({ text: e.token, label: `── ${name} (${n}) ──` })
586 const part = <T,>(name: string, rows: T[], n = rows.length): (T | { text: string; label: string })[] => (rows.length === 0 ? [] : [div(name, n), ...rows])
587 const history = s.history.map((x) => x.item)
588 const histRows = history.slice(0, HISTORY_PICK).map((i) => {
589 const ms = closedAt(i)
590 return { text: e.token, label: `${i.status === 'cancelled' ? '✗' : '✓'} ${mark(i)} ${nameOf(i)}`, description: `${closedLabel(i)} ${ms === null ? '?' : relative(ms, now)} · ${where(i)}` }
591 })
592 const more = history.length - histRows.length
593 const bad = all.filter((i) => i.state === 'unparseable').sort(byDue)
594 const rows = [
595 ...part('fired', firedRows),
596 ...part('running', s.running.map((x) => openRow(x.item, `running · ${relative(x.item.due ?? now, now)}`))),
597 ...part('waiting', s.waiting.map((x) => openRow(x.item, relative(x.item.due ?? now, now)))),
598 ...part('someday', somedayRows),
599 ...part('unreadable', bad.map((i) => ({ text: e.token, label: `${mark(i)} ${nameOf(i)}`, description: `bad due "${i.dueRaw}" · ${where(i)}` }))),
600 ...part('history', [...histRows, ...(more > 0 ? [{ text: e.token, label: `+${more} more · /remind history` }] : [])], history.length),
601 ]
602 const blank = ignored === 0 ? none : { text: e.token, label: `no open or closed reminders · ${ignored} file${ignored === 1 ? '' : 's'} with no known status` }
603 return { suggestions: [...r.suggestions, ...(rows.length === 0 ? [blank] : rows)] }
604 }
605 const rows = [...firedRows, ...(fired.length > 0 && someday.length > 0 ? [{ text: e.token, label: '── someday ──' }] : []), ...somedayRows]
606 if (want !== '') return rows.length === 0 ? r : { suggestions: [...r.suggestions, ...rows] }
607 const nothing = all.length === ignored
608 return { suggestions: [...r.suggestions, ...rows, ...(nothing ? [none] : []), { text: `${prefix}?`, label: `${prefix}? all reminders · project + global` }] }
609 })
610
611 // Highlight tokens that would expand: the submit resolver over the last loaded items; no I/O. `**bold**`, `**2**` never match.
612 on('prompt.edit', async ($, e, next) => {
613 const r = await next(e)
614 const deco = tokensOf(r.text, prefix).flatMap(({ start, end, name }) => (runnable(known, name) === null ? [] : [{ start, end, bold: true, color: 'warning' as const }]))
615 hintTail = deco.length > 0 ? `${prefix}N expands on Enter to "Run reminder ..."` : ''
616 return deco.length === 0 ? r : { ...r, decorations: [...(r.decorations ?? []), ...deco] }
617 })
618
619 on('ui.render', { component: 'PromptHint' }, ($, e, next) => (hintTail === '' ? next(e) : next({ ...e, props: { ...e.props, tail: hintTail } })))
620
621 // `<prefix>N` / `<prefix><id|slug|prefix>` tokens -> runPrompt blocks (the /remind run text, one per reminder), other words follow as extra words.
622 on('prompt.submit', async ($, e, next) => {
623 if (!TYPED_ORIGINS.has(e.origin.kind) || e.command !== undefined || e.isMeta !== undefined || !e.text.includes(prefix)) return next(e)
624 // Terminal only: a bare `<prefix>` prints /remind all, `<prefix>?` also the newest closed (the picker's content); no turn.
625 const bare = e.text.trim()
626 if (e.origin.kind === 'composer' && (bare === prefix || bare === `${prefix}?`)) {
627 const t = await $.clock.now()
628 const dirs = dirsOf(await $.session.root().catch(() => undefined), env)
629 // One log call per line: the terminal draws a newline inside one as a box glyph.
630 for (const line of listText(await loadAll(io, dirs, t), t, tzOf(await $.env.get('TZ')), dirs, query(bare === prefix ? 'all' : 'every')).split('\n')) $.ui.log(line)
631 // The engine puts a dropped prompt back in the box, before keys typed since or (live, 2.1.306) on a new line after them: strip only it.
632 $.clock.after(50, () => {
633 void (async () => {
634 const box = (await $.prompt.read()).text
635 const rest = box.startsWith(e.text) ? box.slice(e.text.length) : box.endsWith(`\n${e.text}`) ? box.slice(0, -(e.text.length + 1)) : null
636 if (rest !== null) await $.prompt.fill({ text: rest, mode: 'replace' })
637 })().catch(() => undefined)
638 })
639 return { drop: 'reminders listed above · no Claude turn' }
640 }
641 const tokens = tokensOf(e.text, prefix)
642 if (tokens.length === 0) return next(e)
643 const now = await $.clock.now()
644 const sid = await $.session.id()
645 const tz = tzOf(await $.env.get('TZ'))
646 const all = await loadAll(io, dirsOf(await $.session.root().catch(() => undefined), env), now)
647 known = all
648 await refresh?.({ items: all, t: now }).catch(() => undefined)
649 const blocks: string[] = []
650 const seen = new Set<string>()
651 const cut: Tok[] = []
652 for (const t of tokens) {
653 const hit = runnable(all, t.name)
654 if (hit === null) continue
655 const foreign = foreignClaim(hit, sid, now)
656 if (foreign !== null) {
657 $.ui.toast(`${hit.id} is already running in another session since ${formatWhen(foreign.at, tz, now)}`, { timeoutMs: 8000 })
658 continue
659 }
660 cut.push(t)
661 const key = `${hit.global ? 'global' : 'project'}:${hit.id}`
662 if (seen.has(key)) continue
663 seen.add(key)
664 blocks.push(runPrompt(hit, bodyOf(await io.read(hit.path)), ''))
665 }
666 if (blocks.length === 0) return next(e)
667 const extra = cut
668 .reduceRight((s, t) => `${s.slice(0, t.start)}${s.slice(t.end)}`, e.text)
669 .replace(/\s+/g, ' ')
670 .trim()
671 return next({ ...e, text: `${blocks.join('\n\n')}${extra === '' ? '' : `\nMy extra words: ${extra}`}` })
672 })
673
674 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
675 const view = await read($, viewAtom)
676 if (e.props.hasSurvey || view.due === 0 || e.props.maxRows < 3) return next(e)
677 const below = await next(e)
678 const { Box, Text, Button } = $.ui.resolve(e)
679 const rows = view.rows.slice(0, Math.max(1, e.props.maxRows - 2))
680 const more = view.due - rows.length
681 const band = Box({
682 key: 'band',
683 flexDirection: 'column',
684 children: [
685 Text({ color: 'warning', bold: true, children: `reminders · ${view.due} fired` }),
686 ...rows.map((r, k) =>
687 Button({
688 key: `r${k + 1}`,
689 hotkey: String(k + 1),
690 plain: true,
691 onPress: () => {
692 void $.prompt.fill({ text: `${prefix}${k + 1}`, mode: 'replace' }).catch(() => undefined)
693 },
694 label: r,
695 }),
696 ),
697 Text({ dimColor: true, wrap: 'truncate-end', children: `press ${rows.length > 1 ? `1-${rows.length}` : '1'} then Enter to run · ${more > 0 ? `+${more} more · ` : ''}${prefix} to pick · ${prefix}? all · /remind snooze · done` }),
698 ],
699 })
700 return below.type === 'engine' ? band : Box({ flexDirection: 'column', children: [below, band] })
701 })
702
703 on('command.run', { command: 'remind' }, async ($, e) => {
704 const now = await $.clock.now()
705 const tz = tzOf(await $.env.get('TZ'))
706 const dirs = dirsOf(await $.session.root().catch(() => undefined), env)
707 const tools = hasTools || (await Promise.race([toolsReady, new Promise<boolean>((resolve) => $.clock.after(3000, () => resolve(false)))]))
708 const words = e.args.trim().split(/\s+/).filter((w) => w !== '')
709 const [head = '', ...rest] = words
710 const verb = head.toLowerCase()
711 if (verb === 'help' && words.length === 1) return { text: HELP(dirs, tools, notifyOn ? `on, last ${lastNotify === '' ? 'none sent yet' : lastNotify}` : 'off (/config -> Reminder desktop alert)', prefix) }
712 const shown =
713 words.length === 0
714 ? query('active')
715 : verb === 'list' && words.length === 1
716 ? query('all')
717 : verb === 'show'
718 ? parseShow(rest, now, tz)
719 : verb === 'all' || verb === 'history' || verb === 'someday'
720 ? parseShow(words, now, tz)
721 : null
722 if (shown !== null) return { text: listText(await loadAll(io, dirs, now), now, tz, dirs, shown) }
723 let refused = ''
724 let outbound: { text: string; ack: string } | null = null
725 if (['run', 'done', 'cancel', 'snooze', 'reopen', 'edit'].includes(verb)) {
726 const need = verb === 'snooze' || verb === 'edit' ? 1 : 0
727 const usage = { text: `usage: /remind ${verb} [project|global] <${verb === 'reopen' ? 'id' : 'N|id'}>${verb === 'snooze' ? ' <when>' : verb === 'edit' ? ' <change>' : verb === 'reopen' ? ' [when]' : verb === 'run' ? ' [words]' : ''}` }
728 if (rest.length === 0) return usage
729 const word = rest[0]?.toLowerCase()
730 const scoped = (word === 'project' || word === 'global') && rest.length >= 2 + need
731 const [id = '', ...tail] = scoped ? rest.slice(1) : rest
732 const scope = scoped ? word : undefined
733 if (verb === 'reopen' && isNum(id)) return { text: 'reopen takes a name: /remind history lists the closed ones' }
734 // free text that merely starts with a verb ("cancel the ...") goes to the model unless the id resolves
735 const all = await loadAll(io, dirs, now)
736 const found = (verb === 'done' || verb === 'cancel') && tail.length > 0 ? null : resolve(all, id, scope)
737 if (typeof found === 'string') {
738 if (!found.startsWith('no reminder matches')) return { text: found }
739 refused = found
740 } else if (found !== null) {
741 if (need === 1 && tail.length === 0) return usage
742 const sid = await $.session.id()
743 const where = found.global ? 'global' : 'project'
744 if (verb === 'run' || verb === 'edit') {
745 if (!tools) return { text: NEEDS_TOOLS }
746 if (found.state === 'done' || found.state === 'cancelled') return { text: `${found.id} is already ${found.state}; /remind reopen ${found.id} brings it back` }
747 if (verb === 'run' && (found.state === 'unparseable' || found.state === 'ignored')) return { text: `${found.id} has an unreadable header; fix the file first` }
748 const foreign = verb === 'run' ? foreignClaim(found, sid, now) : null
749 if (foreign !== null) return { text: `${found.id} is already running in another session since ${formatWhen(foreign.at, tz, now)}` }
750 outbound =
751 verb === 'edit'
752 ? { text: editHandoff(found, tail.join(' '), now, tz), ack: `Handing this edit to Claude: ${found.id}` }
753 : { text: `${HANDOFF_MARK}run ${found.id}</request>\n${runPrompt(found, bodyOf(await io.read(found.path)), tail.join(' '))}`, ack: `Running ${nameOf(found)} (${found.kind}): handing it to Claude` }
754 } else {
755 const git = parseConfig(await io.read(dirs.config).catch(() => '')).git
756 const out = await act(io, dirs, { req: { id: found.id, action: verb, until: tail.join(' '), scope: where }, sid, now, tz, by: 'user', git })
757 await refresh?.().catch(() => undefined)
758 return { text: out.text }
759 }
760 }
761 }
762 if (outbound === null) {
763 const fixed = verb === 'global' ? 'global' : verb === 'project' || verb === 'default' ? 'project' : null
764 const request = (fixed === null ? words : rest).join(' ')
765 if (request === '') return { text: `usage: /remind ${verb} <what and when>` }
766 if (!tools) return { text: refused === '' ? NEEDS_TOOLS : refused }
767 outbound = { text: handoff(request, fixed, now, tz), ack: `Handing this to Claude: ${request}` }
768 }
769 const send = outbound.text
770 // submit is refused inside command.run: hand off from a timer; fill is the fallback.
771 $.clock.after(250, () => {
772 void (async () => {
773 try {
774 await $.prompt.submit({ text: send, asUser: true })
775 } catch {
776 await $.prompt.fill({ text: send, mode: 'replace' }).catch(() => undefined)
777 }
778 })()
779 })
780 return { text: outbound.ack }
781 }).catch((_$, _e, next) => ({ text: `failed: ${next.error.message ?? next.error.kind}` }))
782
783 // The model's tools: answered here, so no permission prompt for the file writes.
784 on('tool.call', { tool: /^mcp__brewmod-reminders__/ }, async ($, e) => {
785 const now = await $.clock.now()
786 const dirs = dirsOf(await $.session.root().catch(() => undefined), env)
787 const tz = tzOf(await $.env.get('TZ'))
788 const git = parseConfig(await io.read(dirs.config).catch(() => '')).git
789 const input = e as unknown as Record<string, unknown>
790 let out: Out
791 if (e.tool === `${TOOL}list_reminders`) {
792 const scope = str(input.scope)
793 const state = STATE_WORDS.get(str(input.state) ?? 'all')
794 const range = rangeOf(str(input.from), str(input.to), now, tz)
795 out =
796 state === undefined
797 ? fail('state is active, all, someday or done')
798 : !range.ok
799 ? fail(range.error)
800 : { text: listText(await loadAll(io, dirs, now), now, tz, dirs, query(state, { scope: scope === 'project' || scope === 'global' ? scope : undefined, from: range.from, to: range.to })) }
801 } else if (e.tool === `${TOOL}add_reminder`) {
802 out = await add(io, dirs, { scope: str(input.scope), when: str(input.when), text: str(input.text), slug: str(input.slug), tz: str(input.tz), kind: str(input.kind) }, now, tz, git)
803 } else if (e.tool === `${TOOL}update_reminder`) {
804 const req = { id: str(input.id) ?? '', action: str(input.action) ?? '', until: str(input.until), result: str(input.result), scope: str(input.scope), fromScope: str(input.from_scope), text: str(input.text), kind: str(input.kind), when: str(input.when) }
805 out = await act(io, dirs, { req, sid: await $.session.id(), now, tz, by: 'model', git })
806 } else out = fail(`unknown tool ${e.tool}`)
807 if (e.tool !== `${TOOL}list_reminders`) await refresh?.().catch(() => undefined)
808 return { result: out.text, ...out }
809 }).catch((_$, _e, next) => {
810 const text = `failed: ${next.error.message ?? next.error.kind}`
811 return { result: text, text, isError: true }
812 })
813}
814hooks/lib/items.mjs 447 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2025-2026 Maksim Kochetkov
3
4// Pure reminder core for hooks/remind.mjs and register.ts: strings + `now` in, values out, no IO.
5
6// Items: <dir>/<YYYYMMDD-HHMM>_<slug>.md, header until the first blank line, id = basename without .md.
7// State: .state/session-<sid>.json {isInteractive,ts} (module writes), .state/offered-<sid>.json {offered:{key:ts}}.
8
9const CLAIM_TTL_MS = 6 * 3600 * 1000
10const LATE_MS = 24 * 3600 * 1000
11export const STATE_TTL_MS = 7 * 24 * 3600 * 1000
12export const CLAIMS_DIR = '.claims'
13const SID_RE = /^[A-Za-z0-9_-]+$/
14const BOM = '\uFEFF'
15const MAX_MS = Date.UTC(9999, 11, 31, 23, 59, 59)
16const GIT_MODES = ['off', 'commit', 'commit-push']
17export const STATE_NAME_RE = /^(session|offered)-[A-Za-z0-9_-]+\.json(\.\d+\.tmp)?$/
18
19export const validSid = (sid) => typeof sid === 'string' && SID_RE.test(sid)
20export const sessionStateName = (sid) => `session-${sid}.json`
21export const offeredStateName = (sid) => `offered-${sid}.json`
22export const claimName = (id) => `${id}.json`
23// id:due, no scope: a scope move keeps the key, so it raises no new fired alert or offer.
24export const itemKey = (item) => `${item.id}:${item.dueRaw}`
25export const tagOf = (item) => `${item.id}${item.global ? ' (global)' : ''}`
26export const slugOf = (id) => id.replace(/^[^_]*_/, '').toLowerCase()
27export const isItemName = (name) => /\.md$/i.test(name) && !name.startsWith('.') && name.toLowerCase() !== 'readme.md'
28
29const pad = (n, w = 2) => String(n).padStart(w, '0')
30export const isoZ = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
31const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
32const parseJson = (text) => {
33 try {
34 const v = JSON.parse(text)
35 return isObject(v) ? v : null
36 } catch {
37 return null
38 }
39}
40
41// ---- state files
42
43export const sessionStateJson = (isInteractive, now) => JSON.stringify({ isInteractive: isInteractive === true, ts: now })
44export function parseSessionState(text) {
45 const v = parseJson(text)
46 return v && typeof v.isInteractive === 'boolean' ? { isInteractive: v.isInteractive } : null
47}
48export const offeredJson = (offered, last) => JSON.stringify({ offered, last })
49export function parseOffered(text) {
50 const o = parseJson(text)?.offered
51 return isObject(o) ? o : {}
52}
53// ms of the last offer: `last`, else the newest mark of an older file, else null (never offered).
54export function parseLastOffer(text) {
55 const v = parseJson(text)
56 if (typeof v?.last === 'number') return v.last
57 const marks = Object.values(isObject(v?.offered) ? v.offered : {}).filter((x) => typeof x === 'number')
58 return marks.length > 0 ? Math.max(...marks) : null
59}
60// Plugin option from a command hook's env (a number as text); unusable -> dflt. min = lowest accepted.
61export function optNumber(raw, dflt, min = 0) {
62 const n = typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN
63 return Number.isFinite(n) && n >= min ? n : dflt
64}
65// An offer goes out on the first prompt, when an item or unreadable file is new since the last offer, or when due items wait and the cooldown passed.
66export function offerNow({ last, hasNew, hasDue, now, cooldownMs }) {
67 return last === null ? hasDue || hasNew : hasNew || (hasDue && now - last >= cooldownMs)
68}
69
70// ---- header and item
71
72function headerRange(lines) {
73 const fenced = lines[0].replace(/^/, '').trim() === '---'
74 const start = fenced ? 1 : 0
75 let end = start
76 while (end < lines.length && lines[end].trim() !== '' && !(fenced && lines[end].trim() === '---')) end++
77 return { start, end, close: fenced && lines[end]?.trim() === '---' }
78}
79// Lines (kept with the first EOL style) and header range of a file's text; the BOM is set aside.
80function splitText(text) {
81 const bom = text.startsWith(BOM) ? BOM : ''
82 const parts = text.slice(bom.length).split(/(\r?\n)/)
83 const lines = parts.filter((_, i) => i % 2 === 0)
84 return { bom, parts, lines, eol: parts[1] ?? '\n', r: headerRange(lines) }
85}
86const KEY_RE = /^([A-Za-z][\w-]*):[ \t]*(.*?)[ \t]*$/
87
88// Strict stored form: UTC with Z; a naive time is unparseable on purpose.
89function parseDueZ(raw) {
90 const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?Z$/.exec(raw ?? '')
91 if (!m) return null
92 const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] ?? 0))
93 const d = new Date(ms)
94 return d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3] && +m[4] < 24 && +m[5] < 60 && +(m[6] ?? 0) < 60 ? ms : null
95}
96
97// -> { id, name, due (ms|null), dueRaw, someday, kind ('action'|'note'), status, created, done, cancelled, result, title }; header keys only, first wins.
98// `due: none` = someday (no date, never fires); `kind: note` = read-and-close, anything else = action.
99export function parseItem(name, text) {
100 const lines = String(text ?? '').replace(/^\uFEFF/, '').split(/\r?\n/)
101 const { start, end, close } = headerRange(lines)
102 const h = {}
103 for (let i = start; i < end; i++) {
104 const m = KEY_RE.exec(lines[i])
105 if (m && !(m[1].toLowerCase() in h)) h[m[1].toLowerCase()] = m[2]
106 }
107 const first = lines.slice(end + (close ? 1 : 0)).find((l) => l.trim() !== '') ?? ''
108 return {
109 id: name.replace(/\.md$/i, ''),
110 name,
111 due: parseDueZ(h.due),
112 dueRaw: h.due ?? '',
113 someday: (h.due ?? '').toLowerCase() === 'none',
114 kind: (h.kind ?? '').toLowerCase() === 'note' ? 'note' : 'action',
115 status: (h.status ?? '').toLowerCase(),
116 created: h.created ?? '',
117 done: h.done ?? '',
118 cancelled: h.cancelled ?? '',
119 result: h.result ?? '',
120 title: first.replace(/^[#>*\-\s]+/, '').trim().slice(0, 80),
121 }
122}
123
124// Rewrites header keys only (value null removes the key); a leading BOM and everything from the blank line on stay byte for byte.
125export function patchHeader(text, patch) {
126 const { bom, parts, lines, eol, r } = splitText(text)
127 const head = lines.slice(0, r.end)
128 for (const [key, value] of Object.entries(patch)) {
129 const idx = head.findIndex((l, i) => i >= r.start && KEY_RE.exec(l)?.[1].toLowerCase() === key)
130 if (value === null) {
131 if (idx >= 0) head.splice(idx, 1)
132 } else if (idx >= 0) head[idx] = `${key}: ${value}`
133 else head.push(`${key}: ${value}`)
134 }
135 return bom + head.join(eol) + (head.length > 0 && r.end < lines.length ? eol : '') + parts.slice(2 * r.end).join('')
136}
137
138// dueMs null = someday (`due: none`); kind omitted = no `kind:` line (reads as action).
139export function buildItem({ dueMs, text, now, kind }) {
140 const k = kind === 'note' || kind === 'action' ? `kind: ${kind}\n` : ''
141 return `due: ${dueMs === null || dueMs === undefined ? 'none' : isoZ(dueMs)}\nstatus: pending\n${k}created: ${isoZ(now)}\n\n${String(text).trim()}\n`
142}
143
144// The body text (after the header and its fence), trimmed.
145export function bodyOf(text) {
146 const lines = String(text ?? '').replace(/^\uFEFF/, '').split(/\r?\n/)
147 const r = headerRange(lines)
148 return lines.slice(r.end + (r.close ? 1 : 0)).join('\n').trim()
149}
150
151// Replaces the body (everything after the header, fence included), header bytes and BOM untouched.
152export function patchBody(text, body) {
153 const { bom, lines, eol, r } = splitText(text)
154 const head = lines.slice(0, r.end + (r.close ? 1 : 0))
155 const b = String(body).trim()
156 return bom + (head.length === 0 ? '' : head.join(eol) + eol + (r.close ? '' : eol)) + b + eol
157}
158
159// Edit one item file: text/kind/dueMs (number = date, null = someday, undefined = keep); a date or someday also sets status pending.
160export function editText(text, { text: body, kind, dueMs }) {
161 const patch = {}
162 if (kind === 'note' || kind === 'action') patch.kind = kind
163 if (dueMs !== undefined) Object.assign(patch, datePatch(dueMs))
164 const withHead = Object.keys(patch).length > 0 ? patchHeader(text, patch) : text
165 return body === undefined ? withHead : patchBody(withHead, body)
166}
167
168// Header patches: snooze/give a date (null = park as someday; a someday item becomes normal), bring a closed item back.
169export const datePatch = (ms) => ({ due: ms === null ? 'none' : isoZ(ms), status: 'pending' })
170export const reopenPatch = (ms) => ({ ...(ms === undefined || ms === null ? {} : { due: isoZ(ms) }), status: 'pending', done: null, cancelled: null, result: null })
171export function slugify(s) {
172 return String(s).toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '') || 'reminder'
173}
174// Someday names `someday_<slug>.md` never start with digits, so they cannot collide with dated names.
175export function itemName(dueMs, slug) {
176 if (dueMs === null || dueMs === undefined) return `someday_${slugify(slug)}.md`
177 const d = new Date(dueMs)
178 return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}_${slugify(slug)}.md`
179}
180
181// ---- claims
182
183export const claimJson = (session, now) => JSON.stringify({ session, at: now })
184export function parseClaim(text) {
185 const v = parseJson(text)
186 return v && typeof v.session === 'string' && typeof v.at === 'number' ? { session: v.session, at: v.at } : null
187}
188export const claimLive = (claim, now) => claim !== null && claim !== undefined && now - claim.at < CLAIM_TTL_MS && claim.at <= now + 60000
189
190// -> 'due' | 'pending' | 'running' | 'someday' | 'done' | 'cancelled' | 'unparseable' (pending, bad due) | 'ignored' (no/unknown status)
191export function classify(item, claim, now) {
192 if (item.status === 'done') return 'done'
193 if (item.status === 'cancelled') return 'cancelled'
194 if (item.status !== 'pending') return 'ignored'
195 if (item.due === null) return item.someday ? 'someday' : 'unparseable'
196 if (claimLive(claim, now)) return 'running'
197 return item.due <= now ? 'due' : 'pending'
198}
199
200// ---- sections, history, cleanup
201
202export const closedAt = (item) => {
203 const ms = Date.parse(item.status === 'cancelled' ? item.cancelled : item.done)
204 return Number.isNaN(ms) ? null : ms
205}
206// 'shown' (closed note) | 'done' | 'cancelled' | '' (not closed)
207export const closedLabel = (item) => (item.status === 'cancelled' ? 'cancelled' : item.status === 'done' ? (item.kind === 'note' ? 'shown' : 'done') : '')
208
209// rows [{ item, state }] -> { fired, running, waiting, someday, history }; fired/running/waiting by due, someday by created, history newest closed first.
210export function sections(rows) {
211 const out = { fired: [], running: [], waiting: [], someday: [], history: [] }
212 const into = { due: 'fired', running: 'running', pending: 'waiting', someday: 'someday', done: 'history', cancelled: 'history' }
213 for (const r of rows) if (into[r.state]) out[into[r.state]].push(r)
214 const byId = (a, b) => (a.item.id < b.item.id ? -1 : a.item.id > b.item.id ? 1 : 0)
215 for (const k of ['fired', 'running', 'waiting']) out[k].sort((a, b) => a.item.due - b.item.due || byId(a, b))
216 out.someday.sort((a, b) => (a.item.created < b.item.created ? -1 : a.item.created > b.item.created ? 1 : byId(a, b)))
217 out.history.sort((a, b) => (closedAt(b.item) ?? -Infinity) - (closedAt(a.item) ?? -Infinity) || byId(a, b))
218 return out
219}
220
221// items -> ids of done/cancelled items closed more than keepDays ago (remind.mjs passes 30); no readable close time = kept; never pending/someday.
222export function expiredIds(items, now, keepDays) {
223 return items.filter((i) => (i.status === 'done' || i.status === 'cancelled') && closedAt(i) !== null && now - closedAt(i) > keepDays * 86400000).map((i) => i.id)
224}
225
226// Accepted forms per caller, for the "unrecognised time" error.
227const BASE_FORMS = 'ISO with offset/Z, "YYYY-MM-DD HH:MM", "YYYY-MM-DD" (00:00 local) or +<n>m|h|d|w'
228const RANGE_FORMS = 'ISO with offset/Z, "YYYY-MM-DD HH:MM", "YYYY-MM-DD" (00:00 local; as to: through that day), +<n>m|h|d|w or today|tomorrow|week|+Nd|+Nw|until YYYY-MM-DD'
229const WHEN_FORMS = 'ISO with offset/Z, "YYYY-MM-DD HH:MM", "YYYY-MM-DD" (09:00 local), "today|tomorrow [HH:MM]" (default 09:00), a weekday like "fri 14:00" (next such day, default 09:00) or +<n>m|h|d|w'
230
231// -> { ok:true, ms } | { ok:false, error }; offset/Z ISO, local `YYYY-MM-DD[ HH:MM[:SS]]` in tz (date only = 00:00), or +<n>m|h|d|w; no past check.
232function parseTime(input, now, tz, forms = BASE_FORMS) {
233 const s = String(input ?? '').trim()
234 let ms = null
235 const rel = /^\+(\d+)([mhdw])$/.exec(s)
236 if (rel) ms = now + +rel[1] * { m: 60000, h: 3600000, d: 86400000, w: 7 * 86400000 }[rel[2]]
237 else {
238 const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(Z|[+-]\d{2}(?::?\d{2})?)?)?$/.exec(s)
239 if (!m) return { ok: false, error: `unrecognised time "${s}": use ${forms}` }
240 const [y, mo, d, h, mi, sec] = [+m[1], +m[2], +m[3], +(m[4] ?? 0), +(m[5] ?? 0), +(m[6] ?? 0)]
241 const naive = Date.UTC(y, mo - 1, d, h, mi, sec)
242 const nd = new Date(naive)
243 if (nd.getUTCMonth() !== mo - 1 || nd.getUTCDate() !== d || h > 23 || mi > 59 || sec > 59) return { ok: false, error: `invalid date/time "${s}"` }
244 if (m[7] === 'Z') ms = naive
245 else if (m[7]) {
246 const o = /^([+-])(\d{2}):?(\d{2})?$/.exec(m[7])
247 ms = naive - (o[1] === '-' ? -1 : 1) * (+o[2] * 60 + +(o[3] ?? 0)) * 60000
248 } else {
249 try {
250 ms = zonedToUtc(naive, tz)
251 } catch {
252 return { ok: false, error: `unknown time zone "${tz}"` }
253 }
254 if (ms === null) return { ok: false, error: `"${s}" does not exist in ${tz} (DST gap)` }
255 }
256 }
257 if (!(ms <= MAX_MS)) return { ok: false, error: `"${s}" is out of range` }
258 return { ok: true, ms: Math.floor(ms / 1000) * 1000 }
259}
260
261// -> { ok:true, ms, iso } | { ok:false, error }; parseTime, a past time rejected.
262export function parseWhen(input, now, tz, forms = BASE_FORMS) {
263 const t = parseTime(input, now, tz, forms)
264 return !t.ok ? t : t.ms <= now ? { ok: false, error: `${isoZ(t.ms)} is in the past` } : { ok: true, ms: t.ms, iso: isoZ(t.ms) }
265}
266
267// Start of the local day `add` days from ms's day in tz (a DST-gap midnight reads as 01:00 minus 1 h).
268function dayStart(ms, tz, add = 0) {
269 const p = zoneParts(ms, tz, 'numeric')
270 const naive = Date.UTC(+p.year, +p.month - 1, +p.day + add)
271 return zonedToUtc(naive, tz) ?? zonedToUtc(naive + 3600000, tz) - 3600000
272}
273const DOW = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }
274
275// today|tomorrow|week|+Nd|+Nw|until YYYY-MM-DD -> { from, to } (to exclusive, local days in tz) | null when not a window word.
276export function windowOf(input, now, tz) {
277 const s = String(input ?? '').trim().toLowerCase()
278 if (s === 'today') return { from: dayStart(now, tz), to: dayStart(now, tz, 1) }
279 if (s === 'tomorrow') return { from: dayStart(now, tz, 1), to: dayStart(now, tz, 2) }
280 if (s === 'week') return { from: now, to: dayStart(now, tz, 8 - DOW[zoneParts(now, tz).weekday]) }
281 const n = /^\+(\d+)([dw])$/.exec(s)
282 if (n) return { from: now, to: now + +n[1] * (n[2] === 'w' ? 7 : 1) * 86400000 }
283 const u = /^until\s+(\d{4}-\d{2}-\d{2})$/.exec(s)
284 const day = u ? parseTime(u[1], now, tz) : null
285 return day?.ok ? { from: now, to: dayStart(day.ms, tz, 1) } : null
286}
287
288// list_reminders from/to (ISO, date, or a window word; a date-only `to` runs through that day) -> { ok:true, from, to } (-/+Infinity when absent) | { ok:false, error }.
289export function rangeOf(from, to, now, tz) {
290 const side = (v, key, dflt) => {
291 if (v === undefined || String(v).trim() === '') return { ok: true, ms: dflt }
292 const w = windowOf(v, now, tz)
293 if (w) return { ok: true, ms: w[key] }
294 const t = parseTime(v, now, tz, RANGE_FORMS)
295 return !t.ok ? t : { ok: true, ms: key === 'to' && /^\d{4}-\d{2}-\d{2}$/.test(String(v).trim()) ? dayStart(t.ms, tz, 1) : t.ms }
296 }
297 const a = side(from, 'from', -Infinity)
298 const lone = (to === undefined || String(to).trim() === '') && from !== undefined ? windowOf(from, now, tz) : null // a window word alone sets both ends
299 const b = lone ? { ok: true, ms: lone.to } : side(to, 'to', Infinity)
300 return !a.ok ? a : !b.ok ? b : { ok: true, from: a.ms, to: b.ms }
301}
302
303// parseWhen plus `today|tomorrow [H[:MM]]`, `<weekday> [H[:MM]]` (next such day after today) and a bare `YYYY-MM-DD`, as wall time in tz, default 09:00.
304const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
305const DAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
306export function parseWhenNatural(input, now, tz) {
307 const s = String(input ?? '').trim()
308 if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return parseWhen(`${s} 09:00`, now, tz, WHEN_FORMS)
309 const m = /^([a-z]+)(?:\s+(\d{1,2})(?::(\d{2}))?)?$/i.exec(s)
310 const word = (m?.[1] ?? '').toLowerCase()
311 const wd = Math.max(DAYS.indexOf(word), DAY_NAMES.indexOf(word))
312 const rel = word === 'today' || word === 'tomorrow'
313 if (!m || (!rel && wd < 0)) return parseWhen(s, now, tz, WHEN_FORMS)
314 let p
315 try {
316 p = zoneParts(now, tz, 'numeric')
317 } catch {
318 return { ok: false, error: `unknown time zone "${tz}"` }
319 }
320 const today = Date.UTC(+p.year, +p.month - 1, +p.day)
321 const add = rel ? (word === 'tomorrow' ? 1 : 0) : ((wd - new Date(today).getUTCDay() + 6) % 7) + 1
322 const d = new Date(today + add * 86400000)
323 return parseWhen(`${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(+(m[2] ?? 9))}:${m[3] ?? '00'}`, now, tz, WHEN_FORMS)
324}
325
326// Today plus the next 8 local calendar days in tz (date arithmetic at UTC noon, so DST never repeats or skips a day).
327export function calendar(now, tz) {
328 const p = zoneParts(now, tz, 'numeric')
329 const f = (ms, o, zone) => new Intl.DateTimeFormat('en-GB', { timeZone: zone, ...o }).format(ms).replace(',', '')
330 const day = (ms, zone) => f(ms, { weekday: 'short', day: 'numeric', month: 'short' }, zone)
331 const next = Array.from({ length: 8 }, (_, k) => day(Date.UTC(+p.year, +p.month - 1, +p.day + k + 1, 12), 'UTC'))
332 return `Today is ${day(now, tz)} ${p.year} ${pad(+p.hour % 24)}:${p.minute} ${tz}; next days: ${next.join(', ')}. Use these weekday/date pairs verbatim in options.`
333}
334
335function zoneParts(ms, tz, month = 'short') {
336 const f = new Intl.DateTimeFormat('en-GB', { timeZone: tz, hourCycle: 'h23', weekday: 'short', year: 'numeric', month, day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' })
337 const p = {}
338 for (const x of f.formatToParts(new Date(ms))) p[x.type] = x.value
339 return p
340}
341function offsetMs(ms, tz) {
342 const p = zoneParts(ms, tz, 'numeric')
343 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute, +p.second) - Math.floor(ms / 1000) * 1000
344}
345// wall-clock fields (as UTC ms) in tz -> real UTC ms; null when the wall time falls in a DST gap
346function zonedToUtc(naive, tz) {
347 let t = naive - offsetMs(naive, tz)
348 t = naive - offsetMs(t, tz)
349 return offsetMs(t, tz) + t === naive ? t : null
350}
351
352export const systemTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
353
354function span(ms) {
355 const a = Math.abs(ms)
356 if (a < 3600000) return `${Math.max(1, Math.round(a / 60000))} min`
357 if (a < 48 * 3600000) return `${Math.round(a / 3600000)} h`
358 return `${Math.round(a / 86400000)} d`
359}
360// 'now' only for due (past or equal); an upcoming item under a minute away reads 'in <1 min'.
361export const relative = (ms, now) =>
362 ms > now ? (ms - now < 60000 ? 'in <1 min' : `in ${span(ms - now)}`) : now - ms < 60000 ? 'now' : `${span(now - ms)} ago`
363
364// "Thu 22 Oct 09:00 Lisbon (08:00 UTC), in 18 h"; UTC date added when it differs from the local date.
365export function formatWhen(ms, tz, now) {
366 const p = zoneParts(ms, tz)
367 const u = new Date(ms)
368 const uHm = `${pad(u.getUTCHours())}:${pad(u.getUTCMinutes())}`
369 const local = `${p.weekday} ${p.day} ${p.month} ${p.hour}:${p.minute}`
370 const rel = relative(ms, now)
371 if (tz === 'UTC' || tz === 'Etc/UTC') return `${local} UTC, ${rel}`
372 const city = tz.split('/').pop().replace(/_/g, ' ')
373 const mo = zoneParts(ms, 'UTC')
374 const sameDay = mo.day === p.day && mo.month === p.month
375 return `${local} ${city} (${sameDay ? '' : `${mo.day} ${mo.month} `}${uHm} UTC), ${rel}`
376}
377
378// ---- matching and gating
379
380// exact id | exact slug | unique prefix of id or slug -> { id } | { error: 'none'|'ambiguous', candidates }
381export function matchId(ids, query) {
382 const q = String(query ?? '').trim().toLowerCase().replace(/\.md$/, '')
383 const pick = (c) => (c.length === 1 ? { id: c[0] } : c.length === 0 ? { error: 'none', candidates: [] } : { error: 'ambiguous', candidates: c })
384 if (q === '') return { error: 'none', candidates: [] }
385 const exact = ids.filter((id) => id.toLowerCase() === q)
386 if (exact.length === 1) return { id: exact[0] }
387 const bySlug = ids.filter((id) => slugOf(id) === q)
388 if (bySlug.length === 1) return { id: bySlug[0] }
389 return pick(ids.filter((id) => id.toLowerCase().startsWith(q) || slugOf(id).startsWith(q)))
390}
391
392// First line of the /remind hand-off prompt (register.ts builds it); the offer skips that turn.
393export const HANDOFF_MARK = 'Reminder helper. My request: <request>'
394// First words of a `**N` expansion (register.ts runPrompt): that turn already carries the reminder.
395export const RUN_MARK = 'Run reminder '
396
397// Offer only on a real typed prompt: source user, or source absent and the module saw an interactive session.
398export function shouldOffer(payload, sessionState) {
399 if (!payload || !validSid(payload.session_id) || payload.agent_id !== undefined) return false
400 const prompt = typeof payload.prompt === 'string' ? payload.prompt.trimStart() : null
401 if (prompt !== null && (/^\/[^\s/]+(\s|$)/.test(prompt) || prompt.startsWith(HANDOFF_MARK) || prompt.startsWith(RUN_MARK))) return false
402 if (payload.source === 'user') return true
403 return payload.source === undefined && sessionState?.isInteractive === true
404}
405
406export function parseConfig(text) {
407 const v = parseJson(text)
408 return { git: v && GIT_MODES.includes(v.git) ? v.git : 'off' }
409}
410
411// ---- offer text
412
413// args: { due:[{id,global,path,due,title,kind,body}], bad:[{id,global,path}], now, tz, git, legacy }
414export function buildOffer({ due, bad, now, tz, git, legacy }) {
415 const out = []
416 const notes = due.filter((i) => i.kind === 'note')
417 const actions = due.filter((i) => i.kind !== 'note')
418 if (due.length > 0) {
419 out.push(`Reminders are due (${due.length}), stored as files:`)
420 for (const i of [...due].sort((a, b) => a.due - b.due)) {
421 const late = now - i.due > LATE_MS ? ` | OVERDUE by ${span(now - i.due)}` : ''
422 out.push(`- ${tagOf(i)} | due ${formatWhen(i.due, tz, now)}${late} | ${i.kind === 'note' ? '✎ note' : '▶ action'} | ${i.title} | ${i.path}`)
423 if (i.kind === 'note') out.push(...String(i.body ?? '').split('\n').map((l) => ` | ${l}`))
424 }
425 out.push("Start your reply with ONE line, in the user's language, saying what fired, before anything else; then continue with the steps below and answer their prompt as usual.")
426 if (actions.length > 0) {
427 out.push('Actions: tell the user in one line, in their language, what fired and offer for each: run now, snooze, mark done, or cancel. Never run one unasked. Say plainly when one is more than 24 h late.')
428 }
429 if (notes.length > 0) {
430 out.push('Notes (kind note, text above after "|"): show each note\'s full text to the user now, say it was just a reminder (nothing to execute), then call update_reminder {id, action:"done"} for it without asking, then answer their prompt as usual.')
431 }
432 out.push(
433 'Run accepted: update_reminder {id, action:"start"}, read the file, carry out its body, then update_reminder {id, action:"done", result}. Snooze: {id, action:"snooze", until}; cancel: {id, action:"cancel"}.',
434 'update_reminder unavailable -> edit the file header: `status: done` plus `done: <UTC now, YYYY-MM-DDTHH:MM:SSZ>` (cancel: `status: cancelled` plus `cancelled: <UTC>`); never touch the body.',
435 )
436 if (git !== 'off') {
437 out.push(`Git: after changing an item file, commit only that file${git === 'commit-push' ? ' and push' : ' (no push)'} following the repo's conventions; never stage anything else.`)
438 }
439 }
440 if (bad.length > 0) {
441 out.push(`Unreadable reminder files (${bad.length}); the \`due:\` line must be UTC ending in Z (YYYY-MM-DDTHH:MM:SSZ). Tell the user and offer to fix:`)
442 for (const i of bad) out.push(`- ${tagOf(i)} | ${i.path}`)
443 }
444 if (legacy) out.push('Legacy: .claude/settings.json still registers the old due-checks.sh hook; tell the user to remove it, brewmod-reminders replaces it.')
445 return out.join('\n')
446}
447types/index.d.ts 14 lines1// SPDX-License-Identifier: MIT
2// Copyright (c) 2025-2026 Maksim Kochetkov
3
4// What the band draws: the fired count and at most 3 row texts; `**N` numbers come from a fresh fired list, not from here.
5export type ReminderView = { due: number; rows: string[] }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'brewmod-reminders': {
10 view: ReminderView
11 }
12 }
13}
14