SLOPSHOPPER

brewmod-reminders

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

newbandspinnerguardcommandtoast
★ 38v6.12.5MITupdated 2026-10-25kochetkov-ma/claude-brewcode/brewmod-reminders
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · brewmod-reminders
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /remind ⎿ brewmod-reminders: No fired reminders; /remind all lists the waiting ones. project /work/app/.claude/scheduled | global /Users/de ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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

What it is

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.

Install

Update Claude Code first: the mods need Claude Code 2.1.301 or newer (claude --version, then claude update and 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.

Timeline

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?").

Kinds

  • action: Claude does the work when you run it ("check the prod logs"), then closes it.
  • note: only reminds you ("build the CSV export"); shown in full, closed once shown.
  • someday: no date, never fires; listed only when you ask (/remind someday, the and ? pickers).

Commands

CommandResult
/remindFired 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)
**1Run 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 allFired, Waiting and Someday
`/remind snooze <N\id> <when>`New time (+2h, tomorrow 9:00, fri 14:00)
/remind helpSummary, 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

Source 3 files
hooks/register.ts 814 lines
1// 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}
814
hooks/lib/items.mjs 447 lines
1// 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}
447
types/index.d.ts 14 lines
1// 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