Asks you to acknowledge the time before prompting inside time windows you configure per day.

A Claude Code plugin that asks you to acknowledge the time before your prompt is sent, inside time windows you configure for each day of the week.
From the Claude Code plugin directory, or from this repository:
/plugin marketplace add jh3ady/claude-time-check
/plugin install time-check@claude-time-check
Run /time-check to open the settings pane. Each field takes comma-separated HH:MM-HH:MM windows and is saved with Enter:
default applies to every day left empty, for example 22:00-06:00, 13:00-14:00.monday to sunday override the default; off disables a day.friday 22:00-06:00 covers Saturday until 06:00.Closing the pane with Escape prints the windows applied to each day. Settings and acknowledgements are stored locally and shared by every session.
The plugin uses the Claude Code function hooks API, which is in early access.
The only prompt the plugin submits is the one you typed: when a prompt is held, its text is kept in the session and submitted unchanged, as your own words, once you pick an option. Nothing is added to it, and no other conversation data is read or sent. Pasted images are not resent with a held prompt.
The full privacy policy describes the data the plugin stores and how to delete it.
What each hook does:
prompt.submit: holds a prompt you typed inside an active window, opens the acknowledgement pane, and lets every other prompt through untouched (notifications, other plugins, its own resubmission).command.run: answers only /time-check, registered at session.start, and opens the settings pane. Other commands are passed on unchanged.ui.close: keeps the acknowledgement pane open until you pick an option, and prints the windows applied when the settings pane closes. Other panes are passed on unchanged.ui.render: draws the plugin's own two panes.hooks/register.tsx 169 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PendingPrompt } from '../types'
5import { DAYS, type ScheduleConfig, describeSchedule, invalidEntries, parseSchedule, warningWindow, windowEnd } from './schedule'
6
7const PANE = 'time-check'
8const SETTINGS_PANE = 'time-check-settings'
9const FIELDS = ['default', ...DAYS] as const
10const DEFAULT_SCHEDULE: ScheduleConfig = { default: '22:00-06:00' }
11const pending = atom({ plugin: 'time-check', key: 'pending' } as const, null)
12const isSessionMuted = atom({ plugin: 'time-check', key: 'isSessionMuted' } as const, false)
13const feedback = atom({ plugin: 'time-check', key: 'feedback' } as const, null)
14
15type Choice = 'hour' | 'window' | 'session'
16
17const twoDigits = (value: number) => String(value).padStart(2, '0')
18
19async function acknowledge($: EngineInterface, choice: Choice) {
20 const prompt = await read($, pending)
21 const now = await $.clock.now()
22
23 if (choice === 'hour') {
24 await $.store.set('lastAckAt', now)
25 } else if (choice === 'window' && prompt) {
26 await $.store.set('snoozedUntil', windowEnd(prompt.window, new Date(now)).getTime())
27 } else if (choice === 'session') {
28 await update($, isSessionMuted, () => true)
29 }
30
31 await update($, pending, () => null)
32 await $.ui.close({ id: PANE })
33
34 if (prompt) {
35 await $.prompt.submit({ text: prompt.text, asUser: true })
36 }
37}
38
39async function readSchedule($: EngineInterface): Promise<ScheduleConfig> {
40 return ((await $.store.get('schedule')) as ScheduleConfig | undefined) ?? DEFAULT_SCHEDULE
41}
42
43async function saveField($: EngineInterface, field: (typeof FIELDS)[number], value: string) {
44 const invalid = invalidEntries(value)
45
46 if (invalid.length > 0) {
47 await update($, feedback, () => `${field}: not saved, ${invalid.join(', ')} is not HH:MM-HH:MM`)
48
49 return
50 }
51
52 await $.store.set('schedule', { ...(await readSchedule($)), [field]: value.trim() })
53 await update($, feedback, () => `${field} saved.`)
54}
55
56export const register: Register = on => {
57 on('session.start', async ($, e, next) => {
58 await $.command.register({ name: 'time-check', description: 'Edit the time windows that ask you to acknowledge the time' })
59
60 return next(e)
61 })
62
63 on('command.run', { command: 'time-check' }, async $ => {
64 await update($, feedback, () => null)
65 await $.ui.open({ id: SETTINGS_PANE, title: 'Time check settings', focus: true, closeOnEscape: true, rows: 10 })
66
67 return { text: 'Time check settings opened.' }
68 })
69
70 on('prompt.submit', async ($, e, next) => {
71 if (e.origin.kind !== 'composer') {
72 return next(e)
73 }
74
75 const now = new Date(await $.clock.now())
76 const { days } = parseSchedule(await readSchedule($))
77 const window = warningWindow(days, now, {
78 lastAckAt: (await $.store.get('lastAckAt')) as number | undefined,
79 snoozedUntil: (await $.store.get('snoozedUntil')) as number | undefined,
80 isSessionMuted: await read($, isSessionMuted),
81 })
82
83 if (!window) {
84 return next(e)
85 }
86
87 // ponytail: the held prompt keeps its text only; pasted images are not resent, keep them in the prompt itself if that matters.
88 const prompt: PendingPrompt = {
89 text: e.text,
90 clock: `${twoDigits(now.getHours())}:${twoDigits(now.getMinutes())}`,
91 window,
92 }
93 await update($, pending, () => prompt)
94 await $.ui.open({ id: PANE, title: 'Time check', focus: true, rows: 3 })
95
96 return { drop: 'Acknowledge the time in the Time check pane; your prompt is sent right after.' }
97 })
98
99 on('ui.close', async ($, e, next) => {
100 const isHeld = e.id === PANE && e.origin.kind === 'person' && (await read($, pending)) !== null
101
102 if (isHeld) {
103 return { deny: 'Pick one of the options to send your prompt.' }
104 }
105
106 const closed = await next(e)
107
108 if (e.id === SETTINGS_PANE) {
109 for (const line of ['Time windows applied:', ...describeSchedule(await readSchedule($))]) {
110 $.ui.log(line)
111 }
112 }
113
114 return closed
115 })
116
117 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
118 const { Box, Button, Text } = $.ui.resolve(e)
119 const prompt = await read($, pending)
120
121 if (!prompt) {
122 return <Text dimColor>Nothing to acknowledge.</Text>
123 }
124
125 return (
126 <Box flexDirection="column">
127 <Text bold>
128 It is {prompt.clock}, inside your {prompt.window.label} time window.
129 </Text>
130 <Box flexDirection="row" gap={1}>
131 <Button key="hour" label="Remind me in 1 hour" onPress={() => acknowledge($, 'hour')} />
132 <Button key="window" label="Don't disturb until this window ends" onPress={() => acknowledge($, 'window')} />
133 <Button key="session" label="Don't disturb this session" onPress={() => acknowledge($, 'session')} />
134 </Box>
135 </Box>
136 )
137 })
138
139 on('ui.render', { component: 'Pane', requestId: SETTINGS_PANE }, async ($, e) => {
140 if (e.surface === 'mobile') {
141 const { Text } = $.ui.resolve(e)
142
143 return <Text>Time windows can be edited from a terminal or desktop session.</Text>
144 }
145
146 const { Box, Input, Text } = $.ui.resolve(e)
147 const schedule = await readSchedule($)
148 const message = await read($, feedback)
149
150 return (
151 <Box flexDirection="column">
152 <Text dimColor>HH:MM-HH:MM windows, comma-separated. Enter saves a field. Empty day uses default, off disables.</Text>
153 {FIELDS.map(field => (
154 <Input
155 key={field}
156 label={field.padEnd(10)}
157 value={schedule[field] ?? ''}
158 placeholder={field === 'default' ? 'off' : 'default'}
159 submitLabel="save"
160 autoFocus={field === 'default' ? true : undefined}
161 onSubmit={(value: string) => saveField($, field, value)}
162 />
163 ))}
164 {message && <Text>{message}</Text>}
165 </Box>
166 )
167 })
168}
169hooks/schedule.ts 111 lines1export const HOUR_MS = 60 * 60 * 1000
2
3export type TimeWindow = { label: string; start: number; end: number }
4
5export type Quiet = {
6 lastAckAt: number | undefined
7 snoozedUntil: number | undefined
8 isSessionMuted: boolean
9}
10
11const WINDOW = /^([01]\d|2[0-3]):([0-5]\d)-([01]\d|2[0-3]):([0-5]\d)$/
12
13export function parseWindows(text: string): { windows: TimeWindow[]; invalid: string[] } {
14 const windows: TimeWindow[] = []
15 const invalid: string[] = []
16
17 for (const entry of text.split(',').map(part => part.trim()).filter(Boolean)) {
18 const match = WINDOW.exec(entry)
19 const start = match ? Number(match[1]) * 60 + Number(match[2]) : 0
20 const end = match ? Number(match[3]) * 60 + Number(match[4]) : 0
21
22 if (match && start !== end) {
23 windows.push({ label: entry, start, end })
24 } else {
25 invalid.push(entry)
26 }
27 }
28
29 return { windows, invalid }
30}
31
32export const invalidEntries = (text: string): string[] =>
33 text.trim() === 'off' ? [] : parseWindows(text).invalid
34
35// Indexed like Date.getDay(): Sunday first.
36export const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const
37
38export type ScheduleConfig = { default?: string } & { [day in (typeof DAYS)[number]]?: string }
39
40export function parseSchedule(config: ScheduleConfig): { days: TimeWindow[][]; invalid: string[] } {
41 const fallback = parseWindows(config.default === 'off' ? '' : (config.default ?? ''))
42 const invalid = fallback.invalid.map(entry => `default: ${entry}`)
43
44 const days = DAYS.map(day => {
45 const text = config[day]?.trim() ?? ''
46
47 if (text === '') {
48 return fallback.windows
49 }
50
51 const parsed = parseWindows(text === 'off' ? '' : text)
52 invalid.push(...parsed.invalid.map(entry => `${day}: ${entry}`))
53
54 return parsed.windows
55 })
56
57 return { days, invalid }
58}
59
60const minuteOfDay = (date: Date) => date.getHours() * 60 + date.getMinutes()
61
62const crossesMidnight = (window: TimeWindow) => window.start > window.end
63
64// A window crossing midnight belongs to the day it starts: its morning half is found on the day before.
65function activeWindow(days: TimeWindow[][], now: Date): TimeWindow | undefined {
66 const minute = minuteOfDay(now)
67 const today = days[now.getDay()] ?? []
68 const yesterday = days[(now.getDay() + 6) % 7] ?? []
69
70 return (
71 today.find(window => minute >= window.start && (crossesMidnight(window) || minute < window.end)) ??
72 yesterday.find(window => crossesMidnight(window) && minute < window.end)
73 )
74}
75
76export function warningWindow(days: TimeWindow[][], now: Date, quiet: Quiet): TimeWindow | undefined {
77 const time = now.getTime()
78 const isAcknowledged = quiet.lastAckAt !== undefined && time - quiet.lastAckAt < HOUR_MS
79 const isSnoozed = quiet.snoozedUntil !== undefined && time < quiet.snoozedUntil
80
81 if (quiet.isSessionMuted || isAcknowledged || isSnoozed) {
82 return undefined
83 }
84
85 return activeWindow(days, now)
86}
87
88export function windowEnd(window: TimeWindow, now: Date): Date {
89 const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, window.end)
90
91 if (crossesMidnight(window) && minuteOfDay(now) >= window.start) {
92 end.setDate(end.getDate() + 1)
93 }
94
95 return end
96}
97
98// Monday first, as people read a week; the stored keys stay day names.
99const WEEK = [...DAYS.slice(1), DAYS[0]]
100
101export function describeSchedule(config: ScheduleConfig): string[] {
102 const fallback = config.default?.trim() || 'off'
103 const days = WEEK.map(day => {
104 const own = config[day]?.trim()
105
106 return `${day.padEnd(10)} ${own || `${fallback} (default)`}`
107 })
108
109 return [`${'default'.padEnd(10)} ${fallback}`, ...days]
110}
111types/index.d.ts 12 lines1export type PendingPrompt = {
2 text: string
3 clock: string
4 window: { label: string; start: number; end: number }
5}
6
7declare module 'claude-code' {
8 interface PluginState {
9 'time-check': { pending: PendingPrompt | null; isSessionMuted: boolean; feedback: string | null }
10 }
11}
12