SLOPSHOPPER

Time Check

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

newpanecommandprompt
v0.1.0MITupdated 2026-10-01jh3ady/claude-time-check
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · time-check
│ ┃ Time check ✕ › fix the failing auth test and add an audit log call │ ┃ It is 01:53, inside your 22:00-06:00 time │ ┃ window. ⏺ Read(src/auth.ts) │ ┃ [ Remind me in 1 hour ] [ Don't disturb unti ⎿ 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 │ │ › /time-check │ ⎿ time-check: Time check settings opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Time check
It is 01:53, inside your 22:00-06:00 time window. [ Remind me in 1 hour ] [ Don't disturb until this window en
Pane · Time check settings
HH:MM-HH:MM windows, comma-separated. Enter saves a field. Empty day uses default, off disables. default : 22:00-06:00 ⏎ save sunday : default ⏎ save monday : default ⏎ save tuesday : default ⏎ save wednesday : default ⏎ save thursday : default ⏎ save friday : default ⏎ save saturday : default ⏎ save
README

Time Check

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.

  • Non-blocking: your prompt is held, not lost. It is sent as soon as you pick an option.
  • Per-day windows: one default, overridden day by day, with windows that may cross midnight.
  • Three answers: remind me in 1 hour, stay quiet until this window ends, or stay quiet for this session.

Installation

From the Claude Code plugin directory, or from this repository:

/plugin marketplace add jh3ady/claude-time-check
/plugin install time-check@claude-time-check

Usage

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.
  • A window crossing midnight belongs to the day it starts: 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.

Prompts and hooks

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.

License

MIT

Source 3 files
hooks/register.tsx 169 lines
1import { 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}
169
hooks/schedule.ts 111 lines
1export 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}
111
types/index.d.ts 12 lines
1export 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