/today: the day's agenda for Claude and for you. Calendar events, E3 deadlines and Reminders are read through the macos and nycu plugin scripts, shown as one…

/todayfor Claude Code: your calendar, E3 deadlines and Reminders in one line above the prompt, and in Claude's context, so it knows your day before you say a word.
claude-code · mod · function-hooks · macos · nycu · agenda
📅 13:20 3D遊戲程式 · ED102 · in 3h 20m 📝 1 due (1 within 3d) ☑ 1 overdue
<sub>The band directly above the prompt: the running or next event with a countdown, deadlines ahead, reminders due. It ticks every minute and re-reads every five, one read shared by every open session.</sub>
Sessions start with "what was I supposed to do today?" and end with a missed class. This mod reads the day once at start, keeps the next thing in view, and hands the whole agenda to Claude as a context block, so "do I have time for this before class?" gets a real answer.
It is a Claude Code mod: a plugin whose behaviour lives in a TypeScript hooks module. The data comes from macos and nycu scripts run as child processes (productivity.py with JSON stdin, and e3p.py), nothing else.
Function hooks are early access, so the engine loads a mod only with the flag on. Put it in your shell profile or in settings.json under env:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
Then install it as a plugin, from GitHub or from a local clone:
/plugin marketplace add zyx1121/today-mod
/plugin install today-mod@today-mod
It needs macos@zyx1121 0.2.0 or later and nycu@zyx1121 installed at user scope (or explicit macosScriptsDir and nycuScriptsDir settings), uv at /opt/homebrew/bin/uv, Full Access to Calendars and Reminders for your terminal (System Settings > Privacy & Security; macos reads both through EventKit, so neither app opens), and an E3 login configured through nycu for deadlines. A source that is missing or denied is reported on the line and in /today; the others still show.
In the session:
/today the whole agenda as Markdown
/today off hide the line (remembered)
/today on show it again
| Part | Content |
|---|---|
📅 | The event running now (now) or the next to start today with a countdown (in 1h 12m); no more events today; nothing on the calendar today |
📝 | E3 deadlines within dueDays, and how many fall within 3 days |
☑ | Reminders overdue, and due later today |
⚠️ | How many sources failed; /today names them |
The today context block carries the same agenda as Markdown: today's events with times and rooms, E3 deadlines with due times and course, open reminders. It is added to the conversation's first message, after the engine's own blocks.
| Field | Type | Default | What it does | |
|---|---|---|---|---|
macosScriptsDir | string | current macos@zyx1121 user installation | Optional directory for macos API v2 productivity.py | |
nycuScriptsDir | string | current nycu@zyx1121 user installation | Optional directory for e3p.py | |
scriptsDir | string | empty | Legacy explicit shared directory; separate overrides take precedence | |
refreshMs | number | 300000 | Milliseconds between re-reads. Every open session shares one read per interval. Floored at 60000. | |
dueDays | number | 7 | Days ahead to list E3 deadlines | |
remindersList | string | TODO | Unique reminder list name or native ID to read | |
showOnStart | boolean | true | Show the line as soon as an interactive session starts. `/today on | off`, once used, wins. |
hooks/register.ts exports register(on, options). On session.start it registers /today, reads the three sources in parallel and starts two timers: a re-read every refreshMs, a redraw every minute for the countdown. command.run answers /today. ui.render on AbovePrompt draws the line. prompt.context adds the today block, waiting up to 8 seconds for a first read still in flight.hooks/shared-read.ts is the reading every session shares: a JSON file at $TMPDIR/today-mod/agenda.json, keyed by the three scripts' argv so another day never matches. A session takes the reading there when it is younger than refreshMs; while another session's read is under way it waits for that one (up to 35 seconds) instead of walking Calendar again. /today answers from a shared reading under a minute old, else reads the sources itself.hooks/agenda.ts builds argv and JSON stdin requests, parses API v2 pages and ISO schedules, and resolves the configured reminder list to a unique ID. Pagination is followed rather than silently truncated. Date-only tasks become due at the end of their local day. Shared-cache keys include request bodies and list selection.hooks/views/text.ts formats the line and the Markdown; hooks/views/band-view.tsx draws the line with Box and Text.types/claude-code.d.ts is the engine contract, copied from anthropics/claude-code/mods/types.bunx -p typescript tsc -p tsconfig.json # typecheck
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test . # 24 tests, the engine's own harness
Issues and PRs are welcome. Ground rules live in CONTRIBUTING.md.
MIT · the class is at 13:20, you have time
hooks/register.tsx 362 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { EngineInterface, On, PluginOptions, Timer } from 'claude-code'
5
6import { agendaOf, argvOf, inputOf, listIdOf, envelopeOf, pathOf, READ_TIMEOUT_MS, SOURCES, type Agenda, type Run, type Source } from './agenda'
7import { cachePathOf, entryOf, isFresh, keptOf, keyOf, runsOf, type Entry } from './shared-read'
8import { bandView } from './views/band-view'
9import { agendaTextOf } from './views/text'
10import { sourceDirs } from './script-paths'
11
12export const COMMAND_NAME = 'today'
13export const CONTEXT_BLOCK = 'today'
14export const DEFAULT_REFRESH_MS = 300000
15export const MIN_REFRESH_MS = 60000
16export const TICK_MS = 60000
17export const DEFAULT_SCRIPTS_DIR = ''
18export const STORE_SHOWN_KEY = 'shown'
19export const SHOWN_TEXT = 'Today shown above the prompt'
20export const HIDDEN_TEXT = 'Today hidden'
21export const NOT_READ_TEXT = 'Today has not been read yet; try again in a moment'
22
23/** How long `prompt.context` waits for a first read still in flight. */
24export const CONTEXT_WAIT_MS = 8000
25
26/** How often a session waiting on another session's read looks at the shared file. */
27export const CLAIM_POLL_MS = 2000
28
29/** A claim older than this (its reader died or hung) is read over. */
30export const CLAIM_MAX_AGE_MS = READ_TIMEOUT_MS + 5000
31
32/** The oldest shared reading /today answers with; older, it reads the sources itself. */
33export const COMMAND_MAX_AGE_MS = 60000
34
35type Settings = {
36 scriptsDir: string
37 macosScriptsDir: string
38 nycuScriptsDir: string
39 refreshMs: number
40 dueDays: number
41 remindersList: string
42 showOnStart: boolean
43}
44
45/**
46 * The settings the options ask for, defaults filled in and floors applied.
47 *
48 * @param options the plugin's userConfig values
49 * @returns the settings
50 */
51export function settingsOf(options: PluginOptions): Settings {
52 const refreshMs = options.refreshMs
53
54 return {
55 scriptsDir: typeof options.scriptsDir === 'string' && options.scriptsDir !== '' ? options.scriptsDir : DEFAULT_SCRIPTS_DIR,
56 macosScriptsDir: typeof options.macosScriptsDir === 'string' ? options.macosScriptsDir : '',
57 nycuScriptsDir: typeof options.nycuScriptsDir === 'string' ? options.nycuScriptsDir : '',
58 refreshMs:
59 typeof refreshMs === 'number' && Number.isFinite(refreshMs) ? Math.max(MIN_REFRESH_MS, Math.floor(refreshMs)) : DEFAULT_REFRESH_MS,
60 dueDays: typeof options.dueDays === 'number' && options.dueDays > 0 ? Math.floor(options.dueDays) : 7,
61 remindersList: typeof options.remindersList === 'string' && options.remindersList !== '' ? options.remindersList : 'TODO',
62 showOnStart: options.showOnStart !== false,
63 }
64}
65
66/**
67 * Whether the band shows at a session's start: the last /today choice the
68 * store kept, else `showOnStart`.
69 *
70 * @param kept what the store holds under STORE_SHOWN_KEY
71 * @param settings the settings
72 * @returns whether to show
73 */
74export function isShownAtStart(kept: unknown, settings: Settings): boolean {
75 return typeof kept === 'boolean' ? kept : settings.showOnStart
76}
77
78type Host = {
79 run: (argv: readonly string[], init: { timeoutMs: number; env: Record<string, string>; stdin?: string }) => Promise<Run>
80 home: () => Promise<string | undefined>
81 installed: () => Promise<string>
82 now: () => Promise<number>
83 sleep: (ms: number) => Promise<void>
84 readShared: () => Promise<Entry | null>
85 writeShared: (entry: Entry) => Promise<void>
86 invalidate: (event: 'ui.render' | 'prompt.context') => void
87 every: (ms: number, fn: () => void) => Timer
88 storeGet: (key: string) => Promise<unknown>
89 storeSet: (key: string, value: unknown) => Promise<void>
90}
91
92/**
93 * The engine calls the band needs, taken off `$`. The shared reading lives
94 * under `TMPDIR`; a file the engine cannot read or write counts as absent.
95 *
96 * @param $ the engine
97 * @returns the host
98 */
99async function hostOf($: EngineInterface): Promise<Host> {
100 const path = cachePathOf(await $.env.get('TMPDIR').catch(() => undefined))
101 const home = await $.env.get('HOME')
102 const config = await $.env.get('CLAUDE_CONFIG_DIR')
103 const registry = `${config || `${home}/.claude`}/plugins/installed_plugins.json`
104
105 return {
106 run: (argv, init) => $.process.run(argv, init),
107 home: () => $.env.get('HOME'),
108 installed: () => $.fs.read(registry).then(text => typeof text === 'string' ? text : '', () => ''),
109 now: () => $.clock.now(),
110 sleep: ms => $.clock.sleep(ms),
111 readShared: () =>
112 $.fs.read(path).then(
113 text => (typeof text === 'string' ? entryOf(text) : null),
114 () => null,
115 ),
116 writeShared: entry => $.fs.write(path, JSON.stringify(entry)).catch(() => undefined),
117 invalidate: event => $.ui.invalidate(event),
118 every: (ms, fn) => $.clock.every(ms, fn),
119 storeGet: key => $.store.get(key),
120 storeSet: (key, value) => $.store.set(key, value),
121 }
122}
123
124/** Read paginated native results and resolve the configured reminder list first. */
125async function readSource(engine: Host, source: Source, argv: readonly string[], input: Record<string, unknown> | undefined, remindersList: string, env: Record<string, string>): Promise<Run> {
126 if (!input) return engine.run(argv, { timeoutMs: READ_TIMEOUT_MS, env })
127 const payload = { ...input }
128 if (source === 'reminders') {
129 const discovery = await engine.run([argv[0]!, 'reminders_list_lists'], { timeoutMs: READ_TIMEOUT_MS, env, stdin: '{}' })
130 const parsed = envelopeOf(discovery)
131 if ('error' in parsed) throw new Error(parsed.error)
132 payload.list_id = listIdOf(parsed.data, remindersList)
133 }
134 const rows: unknown[] = []
135 const cursors = new Set<string>()
136 for (let pages = 0; pages < 100; pages++) {
137 const run = await engine.run(argv, { timeoutMs: READ_TIMEOUT_MS, env, stdin: JSON.stringify(payload) })
138 const parsed = envelopeOf(run)
139 if ('error' in parsed) return run
140 rows.push(...parsed.data)
141 const data = JSON.parse(run.stdout).data
142 const cursor = data?.next_cursor
143 if (!cursor) return { exitCode: 0, stdout: JSON.stringify({ success: true, data: rows }), stderr: '' }
144 if (typeof cursor !== 'string' || cursors.has(cursor)) throw new Error('invalid native pagination cursor')
145 cursors.add(cursor)
146 payload.cursor = cursor
147 }
148 throw new Error('native pagination exceeded 100 pages')
149}
150
151/**
152 * The Today band and context: on an interactive start the mod reads the
153 * three sources, draws the next event above the prompt, re-reads on a timer,
154 * ticks the countdown every minute, answers /today with the whole agenda and
155 * adds a `today` block to the conversation's context.
156 *
157 * @param on the engine's hook registrar
158 * @param options the plugin's userConfig values
159 */
160export function register(on: On, options: PluginOptions): void {
161 const settings = settingsOf(options)
162
163 let agenda: Agenda | null = null
164 let reading: Promise<void> | null = null
165 let refresh: Timer | null = null
166 let tick: Timer | null = null
167 let isShown = false
168 let host: Host | null = null
169
170 async function read(engine: Host, maxAgeMs = settings.refreshMs): Promise<void> {
171 if (reading) {
172 return reading
173 }
174
175 reading = (async () => {
176 const dirs = sourceDirs(settings, await engine.installed())
177 let argvs = {} as Record<Source, readonly string[]>
178 let key = ''
179 let at = 0
180
181 // another session's reading of the same day: take it when recent,
182 // wait for it while that session's read is under way; the day and the
183 // time are taken after each look, so a wait past midnight reads the new day
184 for (;;) {
185 const shared = await engine.readShared()
186
187 at = await engine.now()
188 argvs = {} as Record<Source, readonly string[]>
189
190 for (const source of SOURCES) {
191 argvs[source] = argvOf(source, dirs[source], new Date(at), settings)
192 }
193
194 const requests = {} as Record<Source, readonly string[]>
195 for (const source of SOURCES) requests[source] = [...argvs[source], JSON.stringify(inputOf(source, new Date(at))) ?? '', source === 'reminders' ? settings.remindersList : '']
196 key = keyOf(requests)
197
198 if (shared?.key !== key) {
199 break
200 }
201
202 if (shared.runs && isFresh(shared.readAt, at, maxAgeMs)) {
203 agenda = agendaOf(runsOf(shared.runs), shared.readAt)
204 engine.invalidate('ui.render')
205 engine.invalidate('prompt.context')
206
207 return
208 }
209
210 if (shared.runs || !isFresh(shared.readAt, at, CLAIM_MAX_AGE_MS)) {
211 break
212 }
213
214 await engine.sleep(CLAIM_POLL_MS)
215 }
216
217 await engine.writeShared({ key, readAt: at, runs: null })
218
219 const home = (await engine.home().catch(() => undefined)) ?? '/Users/loki'
220 const env = { PATH: pathOf(home), HOME: home }
221 const runs = {} as Record<Source, Run | Error>
222
223 await Promise.all(
224 SOURCES.map(async source => {
225 if (!dirs[source]) {
226 runs[source] = new Error(`install ${source === 'e3p' ? 'nycu' : 'macos'}@zyx1121 or configure its scripts directory`)
227 return
228 }
229 runs[source] = await readSource(engine, source, argvs[source], inputOf(source, new Date(at)), settings.remindersList, env)
230 .catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))))
231 }),
232 )
233
234 const readAt = await engine.now()
235
236 agenda = agendaOf(runs, readAt)
237 await engine.writeShared({ key, readAt, runs: keptOf(runs) })
238 engine.invalidate('ui.render')
239 engine.invalidate('prompt.context')
240 })().finally(() => {
241 reading = null
242 })
243
244 return reading
245 }
246
247 function start(engine: Host): void {
248 host = engine
249 refresh?.cancel()
250 tick?.cancel()
251 refresh = engine.every(settings.refreshMs, () => {
252 void read(engine)
253 })
254 tick = engine.every(TICK_MS, () => {
255 if (isShown) {
256 engine.invalidate('ui.render')
257 }
258 })
259 }
260
261 function stop(): void {
262 refresh?.cancel()
263 tick?.cancel()
264 refresh = null
265 tick = null
266 }
267
268 on('session.start', async ($, e, next) => {
269 try {
270 await $.command.register({
271 name: COMMAND_NAME,
272 description: "Today's calendar, E3 deadlines and Reminders; `on` or `off` shows or hides the line above the prompt",
273 argumentHint: '[on|off]',
274 })
275 } catch (error) {
276 $.ui.log(`/${COMMAND_NAME} did not register: ${error instanceof Error ? error.message : String(error)}`)
277 }
278
279 if (e.isInteractive && e.surface === 'terminal') {
280 const engine = await hostOf($)
281 const kept = await engine.storeGet(STORE_SHOWN_KEY).catch(() => undefined)
282
283 isShown = isShownAtStart(kept, settings)
284 start(engine)
285 void read(engine)
286 }
287
288 return next(e)
289 })
290
291 on('command.run', { command: COMMAND_NAME }, async ($, e) => {
292 const engine = await hostOf($)
293 const want = e.args.trim().toLowerCase()
294
295 if (want === 'on' || want === 'off') {
296 isShown = want === 'on'
297
298 if (isShown && host === null) {
299 start(engine)
300 void read(engine)
301 }
302
303 engine.invalidate('ui.render')
304 await engine.storeSet(STORE_SHOWN_KEY, isShown).catch(() => undefined)
305
306 return { text: isShown ? SHOWN_TEXT : HIDDEN_TEXT }
307 }
308
309 if (host === null) {
310 start(engine)
311 }
312
313 await read(engine, COMMAND_MAX_AGE_MS)
314
315 return { text: agenda ? agendaTextOf(agenda, new Date(await engine.now())) : NOT_READ_TEXT }
316 })
317
318 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
319 if (!isShown || e.props.hasSurvey) {
320 return next(e)
321 }
322
323 const { Box, Text } = $.ui.resolve(e)
324 const [mine, beneath] = await Promise.all([
325 bandView({ Box, Text }, agenda, new Date(await $.clock.now())),
326 next(e),
327 ])
328
329 return (
330 <Box flexDirection="column">
331 {mine}
332 {beneath}
333 </Box>
334 )
335 })
336
337 on('prompt.context', async ($, e, next) => {
338 if (agenda === null && reading) {
339 await Promise.race([reading, $.clock.sleep(CONTEXT_WAIT_MS)]).catch(() => undefined)
340 }
341
342 const below = await next(e)
343
344 if (agenda === null) {
345 return below
346 }
347
348 const text = agendaTextOf(agenda, new Date(await $.clock.now()))
349
350 return {
351 ...below,
352 blocks: [...below.blocks.filter(block => block.name !== CONTEXT_BLOCK), { name: CONTEXT_BLOCK, text }],
353 }
354 })
355
356 on('session.end', ($, e, next) => {
357 stop()
358
359 return next(e)
360 })
361}
362hooks/agenda.ts 273 lines1/**
2 * What the day holds, read through the macos and nycu plugin scripts and parsed from
3 * their JSON envelopes.
4 */
5
6/** One calendar event, at a minute of the day. */
7export type Event = {
8 title: string
9 /** Minutes after midnight, local; null when the start could not be read. */
10 minute: number | null
11 location: string
12}
13
14/** One reminder, with a due time when it has one. */
15export type Reminder = {
16 name: string
17 due: Date | null
18}
19
20/** One E3 deadline. */
21export type Deadline = {
22 name: string
23 course: string
24 due: Date
25}
26
27/** Everything read, and what failed. */
28export type Agenda = {
29 events: Event[]
30 reminders: Reminder[]
31 deadlines: Deadline[]
32 /** One line per source that failed, `calendar: …`. */
33 errors: string[]
34 /** When the read finished, epoch ms. */
35 readAt: number
36}
37
38/** A finished run of one script. */
39export type Run = { exitCode: number; stdout: string; stderr: string }
40
41/** The three sources, in the order they are read and reported. */
42export const SOURCES = ['calendar', 'reminders', 'e3p'] as const
43
44export type Source = (typeof SOURCES)[number]
45
46/** How long one script may take before it counts as failed. */
47export const READ_TIMEOUT_MS = 30000
48
49/**
50 * The PATH a child needs to find `uv` (the scripts' shebang) without a shell.
51 *
52 * @param home the person's home directory
53 * @returns the PATH value
54 */
55export function pathOf(home: string): string {
56 return ['/opt/homebrew/bin', `${home}/.local/bin`, `${home}/.bun/bin`, '/usr/local/bin', '/usr/bin', '/bin'].join(':')
57}
58
59/**
60 * `YYYY-MM-DD` of a local date.
61 *
62 * @param date the date
63 * @returns the day
64 */
65export function dayOf(date: Date): string {
66 const y = date.getFullYear()
67 const m = String(date.getMonth() + 1).padStart(2, '0')
68 const d = String(date.getDate()).padStart(2, '0')
69
70 return `${y}-${m}-${d}`
71}
72
73/**
74 * The argv that reads one source for `now`'s day.
75 *
76 * @param source which script
77 * @param scriptsDir where the scripts live
78 * @param now the moment
79 * @param options the reminders list and the E3 window
80 * @returns the argv
81 */
82export function argvOf(
83 source: Source,
84 scriptsDir: string,
85 now: Date,
86 options: { remindersList: string; dueDays: number },
87): readonly string[] {
88 switch (source) {
89 case 'calendar':
90 return [`${scriptsDir}/productivity.py`, 'calendar_list_events']
91 case 'reminders':
92 return [`${scriptsDir}/productivity.py`, 'reminders_list']
93 case 'e3p':
94 return [`${scriptsDir}/e3p.py`, 'due', '--days', String(options.dueDays), '--limit', '50']
95 }
96}
97
98/** The JSON request body for a native source; timezone offsets are explicit. */
99export function inputOf(source: Source, now: Date): Record<string, unknown> | undefined {
100 if (source === 'e3p') return undefined
101 if (source === 'reminders') return { completed: false, limit: 100 }
102 const from = new Date(now.getFullYear(), now.getMonth(), now.getDate())
103 const to = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
104 return { from: from.toISOString(), to: to.toISOString(), limit: 100 }
105}
106
107/** Resolve a configured display name to one native list ID, never the first match. */
108export function listIdOf(rows: unknown[], selector: string): string {
109 const matches = rows.filter((row): row is Record<string, unknown> => isRecord(row) && (row.id === selector || row.title === selector))
110 if (matches.length !== 1 || typeof matches[0]?.id !== 'string') throw new Error(`reminder list '${selector}' is missing or ambiguous; configure its ID`)
111 return matches[0].id
112}
113
114/** Parse v2 date-only or offset-bearing schedules. Date-only tasks are due at the end of their local day. */
115export function scheduleOf(value: unknown): Date | null {
116 if (!isRecord(value)) return null
117 if (value.kind === 'datetime' && typeof value.at === 'string') {
118 const parsed = new Date(value.at)
119 return Number.isNaN(parsed.getTime()) ? null : parsed
120 }
121 if (value.kind === 'date' && typeof value.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value.date)) {
122 const [y,m,d] = value.date.split('-').map(Number)
123 return new Date(y!,m!-1,d!,23,59,59,999)
124 }
125 return null
126}
127
128function isRecord(value: unknown): value is Record<string, unknown> {
129 return typeof value === 'object' && value !== null && !Array.isArray(value)
130}
131
132/**
133 * The `data` of a utils envelope, or the reason there is none.
134 *
135 * @param run the finished script
136 * @returns `{ data }` or `{ error }`
137 */
138export function envelopeOf(run: Run): { data: unknown[] } | { error: string } {
139 let parsed: unknown
140
141 try {
142 parsed = JSON.parse(run.stdout)
143 } catch {
144 const tail = run.stderr.trim().split('\n').at(-1) ?? ''
145
146 return { error: run.exitCode === 0 ? 'answered something that is not JSON' : `exited ${run.exitCode}${tail ? `: ${tail}` : ''}` }
147 }
148
149 if (!isRecord(parsed)) {
150 return { error: 'answered something that is not an envelope' }
151 }
152
153 if (parsed.success !== true) {
154 const error = isRecord(parsed.error) ? parsed.error : {}
155 const message = typeof error.message === 'string' ? error.message : 'failed'
156 const hint = typeof error.hint === 'string' ? ` (${error.hint})` : ''
157
158 return { error: `${message}${hint}` }
159 }
160
161 return { data: Array.isArray(parsed.data) ? parsed.data : isRecord(parsed.data) && Array.isArray(parsed.data.items) ? parsed.data.items : [] }
162}
163
164/**
165 * API v2 calendar occurrences as local event times. Date-only events start at midnight.
166 *
167 * @param rows the envelope's data
168 * @returns the events, earliest first
169 */
170export function eventsOf(rows: unknown[]): Event[] {
171 const events: Event[] = []
172
173 for (const row of rows) {
174 if (!isRecord(row) || typeof row.title !== 'string') {
175 continue
176 }
177
178 const start = scheduleOf(row.start)
179
180 events.push({
181 title: row.title,
182 minute: isRecord(row.start) && row.start.kind === 'date' ? 0 : start ? start.getHours() * 60 + start.getMinutes() : null,
183 location: typeof row.location === 'string' ? row.location.trim() : '',
184 })
185 }
186
187 return events.sort((a, b) => (a.minute ?? 1e9) - (b.minute ?? 1e9))
188}
189
190/**
191 * Reminder rows as reminders, the undone ones only.
192 *
193 * @param rows the envelope's data
194 * @returns the reminders, due ones first
195 */
196export function remindersOf(rows: unknown[]): Reminder[] {
197 const reminders: Reminder[] = []
198
199 for (const row of rows) {
200 if (!isRecord(row) || typeof row.title !== 'string' || row.completed === true) {
201 continue
202 }
203
204 reminders.push({
205 name: row.title,
206 due: scheduleOf(row.due),
207 })
208 }
209
210 return reminders.sort((a, b) => (a.due?.getTime() ?? Infinity) - (b.due?.getTime() ?? Infinity))
211}
212
213/**
214 * E3 rows as deadlines.
215 *
216 * @param rows the envelope's data
217 * @returns the deadlines, soonest first
218 */
219export function deadlinesOf(rows: unknown[]): Deadline[] {
220 const deadlines: Deadline[] = []
221
222 for (const row of rows) {
223 if (!isRecord(row) || typeof row.name !== 'string' || typeof row.timesort !== 'number') {
224 continue
225 }
226
227 deadlines.push({
228 name: row.name,
229 course: typeof row.course === 'string' ? row.course : '',
230 due: new Date(row.timesort * 1000),
231 })
232 }
233
234 return deadlines.sort((a, b) => a.due.getTime() - b.due.getTime())
235}
236
237/**
238 * The agenda three finished runs make.
239 *
240 * @param runs each source's run, or the error that stopped it
241 * @param readAt when the reads finished
242 * @returns the agenda
243 */
244export function agendaOf(runs: Record<Source, Run | Error>, readAt: number): Agenda {
245 const agenda: Agenda = { events: [], reminders: [], deadlines: [], errors: [], readAt }
246
247 for (const source of SOURCES) {
248 const run = runs[source]
249
250 if (run instanceof Error) {
251 agenda.errors.push(`${source}: ${run.message}`)
252 continue
253 }
254
255 const envelope = envelopeOf(run)
256
257 if ('error' in envelope) {
258 agenda.errors.push(`${source}: ${envelope.error}`)
259 continue
260 }
261
262 if (source === 'calendar') {
263 agenda.events = eventsOf(envelope.data)
264 } else if (source === 'reminders') {
265 agenda.reminders = remindersOf(envelope.data)
266 } else {
267 agenda.deadlines = deadlinesOf(envelope.data)
268 }
269 }
270
271 return agenda
272}
273hooks/shared-read.ts 149 lines1/**
2 * One reading of the day shared by every session on the machine: each
3 * session looks at a small file under the temporary directory first and runs
4 * the three scripts only when the reading there is older than it can use, so
5 * five open sessions cost one Calendar walk per refresh, not five.
6 */
7import { SOURCES, type Run, type Source } from './agenda'
8
9/** A source's outcome as the file keeps it: the run, or why it did not run. */
10export type Kept = Run | { error: string }
11
12/**
13 * What the shared file holds: which reads it answers (`key`, the argv of
14 * each source, so another day or other settings never match), when it was
15 * taken (milliseconds since the epoch), and the runs, or null while a read
16 * is still under way.
17 */
18export type Entry = { key: string; readAt: number; runs: Record<Source, Kept> | null }
19
20/** The shared file's name under the temporary directory. */
21export const CACHE_NAME = 'today-mod/agenda.json'
22
23/**
24 * The shared file's path.
25 *
26 * @param tmpdir `TMPDIR`, when set
27 * @returns the path under it, else under /tmp
28 */
29export function cachePathOf(tmpdir: string | undefined): string {
30 const dir = tmpdir && tmpdir !== '' ? tmpdir : '/tmp'
31
32 return `${dir.replace(/\/+$/, '')}/${CACHE_NAME}`
33}
34
35/**
36 * The key a read answers: the argv of every source, in order.
37 *
38 * @param argvs each source's argv
39 * @returns the key
40 */
41export function keyOf(argvs: Record<Source, readonly string[]>): string {
42 return JSON.stringify(SOURCES.map(source => argvs[source]))
43}
44
45function isKept(value: unknown): value is Kept {
46 if (typeof value !== 'object' || value === null) {
47 return false
48 }
49
50 const kept = value as Record<string, unknown>
51
52 if (typeof kept.error === 'string') {
53 return true
54 }
55
56 return typeof kept.exitCode === 'number' && typeof kept.stdout === 'string' && typeof kept.stderr === 'string'
57}
58
59/**
60 * The runs as the file keeps them: an Error becomes its message.
61 *
62 * @param runs each source's run or error
63 * @returns the JSON-safe runs
64 */
65export function keptOf(runs: Record<Source, Run | Error>): Record<Source, Kept> {
66 const kept = {} as Record<Source, Kept>
67
68 for (const source of SOURCES) {
69 const run = runs[source]
70
71 kept[source] = run instanceof Error ? { error: run.message } : run
72 }
73
74 return kept
75}
76
77/**
78 * The runs the file kept, an error message back as an Error.
79 *
80 * @param kept what the file holds
81 * @returns each source's run or error
82 */
83export function runsOf(kept: Record<Source, Kept>): Record<Source, Run | Error> {
84 const runs = {} as Record<Source, Run | Error>
85
86 for (const source of SOURCES) {
87 const one = kept[source]
88
89 runs[source] = 'error' in one ? new Error(one.error) : one
90 }
91
92 return runs
93}
94
95/**
96 * The entry a shared file's text stands for.
97 *
98 * @param text the file's content
99 * @returns the entry, or null when the text is not one
100 */
101export function entryOf(text: string): Entry | null {
102 let parsed: unknown
103
104 try {
105 parsed = JSON.parse(text)
106 } catch {
107 return null
108 }
109
110 if (typeof parsed !== 'object' || parsed === null) {
111 return null
112 }
113
114 const { key, readAt, runs } = parsed as Record<string, unknown>
115
116 if (typeof key !== 'string' || typeof readAt !== 'number' || !Number.isFinite(readAt)) {
117 return null
118 }
119
120 if (runs === null) {
121 return { key, readAt, runs: null }
122 }
123
124 if (typeof runs !== 'object' || runs === undefined) {
125 return null
126 }
127
128 const all = runs as Record<string, unknown>
129
130 if (!SOURCES.every(source => isKept(all[source]))) {
131 return null
132 }
133
134 return { key, readAt, runs: all as Record<Source, Kept> }
135}
136
137/**
138 * Whether a reading at `readAt` is recent enough at `at`. A reading from the
139 * future (a clock set back) is never fresh.
140 *
141 * @param readAt when it was taken
142 * @param at now
143 * @param maxAgeMs the oldest the caller takes
144 * @returns whether to use it
145 */
146export function isFresh(readAt: number, at: number, maxAgeMs: number): boolean {
147 return readAt <= at && at - readAt < maxAgeMs
148}
149hooks/views/band-view.tsx 37 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { Elements, RenderElement } from 'claude-code'
5
6import type { Agenda } from '../agenda'
7import { bandTextOf } from './text'
8
9/** The tags the band draws with. */
10export type Kit = Pick<Elements['terminal'], 'Box' | 'Text'>
11
12/**
13 * The band's tree for one `ui.render`: one line above the prompt.
14 *
15 * @param kit Box and Text
16 * @param agenda what was last read, or null before the first read lands
17 * @param now the moment
18 * @returns the tree
19 */
20export function bandView(kit: Kit, agenda: Agenda | null, now: Date): RenderElement {
21 const { Box, Text } = kit
22
23 if (agenda === null) {
24 return (
25 <Box paddingX={1}>
26 <Text dimColor>📅 Reading today…</Text>
27 </Box>
28 )
29 }
30
31 return (
32 <Box paddingX={1}>
33 <Text wrap="truncate-end">{bandTextOf(agenda, now)}</Text>
34 </Box>
35 )
36}
37hooks/views/text.ts 160 lines1import type { Agenda, Deadline, Event, Reminder } from '../agenda'
2
3/** How long an event without an end is taken to run, for "now". */
4export const EVENT_SPAN_MIN = 110
5
6const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
7
8/**
9 * `HH:MM` of a minute of the day.
10 *
11 * @param minute minutes after midnight
12 * @returns the clock
13 */
14export function clockOf(minute: number): string {
15 return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`
16}
17
18/**
19 * `1h 12m`, `45m`, or `2d` for a span.
20 *
21 * @param minutes a non-negative span
22 * @returns the text
23 */
24export function spanOf(minutes: number): string {
25 const whole = Math.max(0, Math.round(minutes))
26
27 if (whole >= 48 * 60) {
28 return `${Math.floor(whole / (24 * 60))}d`
29 }
30
31 if (whole >= 60) {
32 const rest = whole % 60
33
34 return rest === 0 ? `${Math.floor(whole / 60)}h` : `${Math.floor(whole / 60)}h ${rest}m`
35 }
36
37 return `${whole}m`
38}
39
40/**
41 * `Wed 09-23 23:59` for a Date.
42 *
43 * @param date the moment
44 * @returns the text
45 */
46export function whenOf(date: Date): string {
47 const mm = String(date.getMonth() + 1).padStart(2, '0')
48 const dd = String(date.getDate()).padStart(2, '0')
49
50 return `${WEEKDAYS[date.getDay()]} ${mm}-${dd} ${clockOf(date.getHours() * 60 + date.getMinutes())}`
51}
52
53/**
54 * The event to point at: one running now, else the next to start today.
55 *
56 * @param events today's events
57 * @param nowMinute minutes after midnight now
58 * @returns `{ event, kind }` or null past the last one
59 */
60export function pointerOf(
61 events: readonly Event[],
62 nowMinute: number,
63): { event: Event; kind: 'now' | 'next' } | null {
64 const timed = events.filter((event): event is Event & { minute: number } => event.minute !== null)
65 const running = timed.find(event => event.minute <= nowMinute && nowMinute < event.minute + EVENT_SPAN_MIN)
66
67 if (running) {
68 return { event: running, kind: 'now' }
69 }
70
71 const next = timed.find(event => event.minute > nowMinute)
72
73 return next ? { event: next, kind: 'next' } : null
74}
75
76/**
77 * The one line above the prompt.
78 *
79 * @param agenda what was read
80 * @param now the moment
81 * @returns `📅 13:20 3D遊戲程式 · ED102 · in 1h 12m 📝 2 due ☑ 1 overdue`
82 */
83export function bandTextOf(agenda: Agenda, now: Date): string {
84 const nowMinute = now.getHours() * 60 + now.getMinutes()
85 const pointer = pointerOf(agenda.events, nowMinute)
86 const parts: string[] = []
87
88 if (pointer) {
89 const { event, kind } = pointer
90 const where = event.location ? ` · ${event.location}` : ''
91 const when = kind === 'now' ? 'now' : `in ${spanOf(event.minute! - nowMinute)}`
92
93 parts.push(`📅 ${clockOf(event.minute!)} ${event.title}${where} · ${when}`)
94 } else {
95 parts.push(agenda.events.length === 0 ? '📅 nothing on the calendar today' : '📅 no more events today')
96 }
97
98 const dueSoon = agenda.deadlines.filter(deadline => deadline.due.getTime() - now.getTime() < 3 * 24 * 60 * 60 * 1000)
99
100 if (agenda.deadlines.length > 0) {
101 parts.push(`📝 ${agenda.deadlines.length} due${dueSoon.length ? ` (${dueSoon.length} within 3d)` : ''}`)
102 }
103
104 const overdue = agenda.reminders.filter(reminder => reminder.due !== null && reminder.due.getTime() <= now.getTime())
105 const dueToday = agenda.reminders.filter(
106 reminder => reminder.due !== null && reminder.due.getTime() > now.getTime() && reminder.due.toDateString() === now.toDateString(),
107 )
108
109 if (overdue.length || dueToday.length) {
110 parts.push(`☑ ${[overdue.length ? `${overdue.length} overdue` : null, dueToday.length ? `${dueToday.length} today` : null].filter(Boolean).join(', ')}`)
111 }
112
113 if (agenda.errors.length) {
114 parts.push(`⚠️ ${agenda.errors.length} source${agenda.errors.length > 1 ? 's' : ''} failed`)
115 }
116
117 return parts.join(' ')
118}
119
120function eventLine(event: Event): string {
121 const at = event.minute === null ? '--:--' : clockOf(event.minute)
122
123 return `- ${at} ${event.title}${event.location ? ` (${event.location})` : ''}`
124}
125
126function deadlineLine(deadline: Deadline): string {
127 return `- ${whenOf(deadline.due)} ${deadline.name}${deadline.course ? ` [${deadline.course}]` : ''}`
128}
129
130function reminderLine(reminder: Reminder): string {
131 return `- ${reminder.due ? `${whenOf(reminder.due)} ` : ''}${reminder.name}`
132}
133
134/**
135 * The agenda as Markdown: the `today` context block, and /today's answer.
136 *
137 * @param agenda what was read
138 * @param now the moment
139 * @returns the text
140 */
141export function agendaTextOf(agenda: Agenda, now: Date): string {
142 const lines: string[] = [`Today is ${WEEKDAYS[now.getDay()]} ${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}, now ${clockOf(now.getHours() * 60 + now.getMinutes())} (local time).`, '']
143
144 lines.push('Calendar today:')
145 lines.push(...(agenda.events.length ? agenda.events.map(eventLine) : ['- nothing']))
146 lines.push('')
147 lines.push('E3 deadlines ahead:')
148 lines.push(...(agenda.deadlines.length ? agenda.deadlines.map(deadlineLine) : ['- none']))
149 lines.push('')
150 lines.push('Reminders open:')
151 lines.push(...(agenda.reminders.length ? agenda.reminders.map(reminderLine) : ['- none']))
152
153 if (agenda.errors.length) {
154 lines.push('', 'Sources that failed:')
155 lines.push(...agenda.errors.map(error => `- ${error}`))
156 }
157
158 return lines.join('\n')
159}
160hooks/script-paths.ts 25 lines1import type { Source } from './agenda'
2
3export type SourceOptions = { scriptsDir: string; macosScriptsDir: string; nycuScriptsDir: string }
4
5/** Find the current user installation instead of hard-coding a cache version. */
6export function installedScripts(text: string, name: string): string {
7 try {
8 const registry = JSON.parse(text) as { plugins?: Record<string, { scope?: string; installPath?: string }[]> }
9 const entries = registry.plugins?.[`${name}@zyx1121`]
10 const entry = Array.isArray(entries) ? entries.find(item => item.scope === 'user' && typeof item.installPath === 'string') : undefined
11
12 return entry?.installPath ? `${entry.installPath}/scripts` : ''
13 } catch {
14 return ''
15 }
16}
17
18/** Explicit domain overrides, then legacy scriptsDir, then installed plugins. */
19export function sourceDirs(options: SourceOptions, registry: string): Record<Source, string> {
20 const macos = options.macosScriptsDir || options.scriptsDir || installedScripts(registry, 'macos')
21 const nycu = options.nycuScriptsDir || options.scriptsDir || installedScripts(registry, 'nycu')
22
23 return { calendar: macos, reminders: macos, e3p: nycu }
24}
25