A clock for a persistent home session: scheduled wakes in the live thread, private hours for the agent, an idle reach, and a live-time stamp beside each…

A clock for an AI that lives in one persistent Claude Code session. Every 60 seconds it checks a schedule and, when a wake is due, submits it as a prompt into the live session, so the agent wakes with the whole conversation still warm. There are no side agents and no separate processes: the thread is the record.
What it does:
wakes.json. A wake is either user (aimed at the person: a check-in, with a push notification to their phone) or own (the agent's private hour in the same thread). A free wake owes nothing at all.mcp__clock__carry and shown at the top of that wake next time.own wake can continue itself later (mcp__clock__again, 20 to 180 minutes, up to three times a day)./wakes lists wakes and when each last fired; /wake <id> fires one now.~/.claude/mods/clock.CLAUDE_CODE_PLUGIN_DIRS=/path/to/clock (colon-separate several) in the environment that starts Claude Code, or pass --plugin-dir /path/to/clock on the command line.cp wakes.example.json wakes.json, then edit it. Fields: timezone (an IANA name such as Europe/London; default UTC), graceMinutes, userName (how the wake text refers to the person), stampMessages, idleReach, inbox, and wakes.id, name, time (HH:MM in your timezone), days (*, or weekday numbers with 0 as Sunday, such as 1-5 or 0,6), kind (user or own), enabled, file, and optionally free. Write each wake as a markdown file; see wakes/ for three neutral examples. The wake text is your own to write in your own voice.CLOCK_HOME=1 is required in the home session. The clock keeps time only where this is set, so other sessions (a second terminal, a remote window) stay quiet.CLOCK_PIN_FILE (optional): a file path. When set, the home session id is written there so a launcher can resume exactly that session.carry-seed.json: { "<wake id>": "<text>" } pre-fills carry notes once, on first run.GET <url>?after=<cursor>&limit=<n> returning { "events": [ { "seq", "kind": "mention|reply|everyone", "channel_name", "message_id", "author": { "name", "bot" }, "content", "ts" } ], "next": <cursor> }. Put the URL in url, or in a file named by urlFile if it is a secret.A minimal launcher, for reference:
CLOCK_HOME=1 CLAUDE_CODE_PLUGIN_DIRS="$HOME/.claude/mods/clock" claude --continue
claude plugin validate .
Mods (function hooks) are an early-access feature of Claude Code. The surface may change between releases without notice, and this mod may need updating when it does. Claude Code writes the type declarations the mod uses into .claude-plugin/types/ itself when it loads the mod, so they are not included here. Wake prompts spend real model turns: keep the schedule modest.
hooks/register.ts 311 lines1import type { Register } from 'claude-code'
2
3// clock: scheduled wakes for a persistent home session.
4// Every 60s the tick checks wakes.json and fires due wakes INSIDE the one live session:
5// kind "own": the agent's own hour, submitted as a prompt into the home thread (warm context, no side agents).
6// kind "user": a wake aimed at the person (check-ins); the wake text tells the model to send a PushNotification.
7// Plus: carry (a 600-char cursor per wake), again (an "own" wake can continue itself, up to 3x a day),
8// an idle reach (once per silence, inside a daily window), a live-time stamp beside each person message,
9// an optional inbox poller, and an optional home-session pin file.
10// Only the home session keeps time: it must run with CLOCK_HOME=1 in its environment.
11
12type Wake = { id: string; name: string; time: string; days: string; kind: 'user' | 'own'; enabled: boolean; file: string; free?: boolean }
13type Inbox = { enabled: boolean; url?: string; urlFile?: string; pageLimit?: number; maxPages?: number; quiet?: { from: string; to: string } }
14type InboxEvent = { seq: number; kind: 'mention' | 'reply' | 'everyone'; channel_name?: string; thread_parent?: string | null; message_id: string; author?: { name?: string; bot?: boolean }; content?: string; truncated?: boolean; ts?: string }
15type Config = {
16 timezone: string; graceMinutes: number; userName?: string; stampMessages?: boolean
17 idleReach: { enabled: boolean; afterMinutes: number; from: string; to: string }
18 inbox?: Inbox; wakes: Wake[]
19}
20type Now = { date: string; hm: string; minutes: number; dow: number }
21
22const DEFAULTS: Config = {
23 timezone: 'UTC', graceMinutes: 10, userName: 'the user', stampMessages: true,
24 idleReach: { enabled: false, afterMinutes: 90, from: '09:00', to: '23:00' }, wakes: [],
25}
26
27const DOW: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
28const toMin = (hm: string) => { const [h, m] = hm.split(':').map(Number); return (h ?? 0) * 60 + (m ?? 0) }
29
30function zonedNow(ms: number, tz: string): Now {
31 const parts = new Intl.DateTimeFormat('en-GB', {
32 timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
33 hour: '2-digit', minute: '2-digit', weekday: 'short', hour12: false,
34 }).formatToParts(new Date(ms))
35 const get = (t: string) => parts.find(p => p.type === t)?.value ?? ''
36 const hour = get('hour') === '24' ? '00' : get('hour')
37 const hm = `${hour}:${get('minute')}`
38 return { date: `${get('year')}-${get('month')}-${get('day')}`, hm, minutes: toMin(hm), dow: DOW[get('weekday')] ?? 0 }
39}
40
41function dayMatches(days: string, dow: number): boolean {
42 if (days === '*') return true
43 return days.split(',').some(part => {
44 const [a, b] = part.split('-').map(Number)
45 return b === undefined ? a === dow : (a ?? 0) <= dow && dow <= b
46 })
47}
48
49let timer: { cancel: () => void } | undefined
50let homeSid: string | undefined
51
52// Optional: pin the home session id to a file, so a launcher can resume this exact session.
53// Set CLOCK_PIN_FILE to a path to enable it.
54async function recordHome($: any) {
55 if ((await $.env.get('CLOCK_HOME')) !== '1') return
56 const file = await $.env.get('CLOCK_PIN_FILE')
57 if (!file) return
58 const sid = String(await $.session.id())
59 if (!sid || sid === homeSid) return
60 await $.fs.write(file, sid + '\n')
61 homeSid = sid
62}
63
64async function config($: any): Promise<Config> {
65 try {
66 const c = JSON.parse(await $.fs.read(`${$.plugin.root}/wakes.json`)) as Partial<Config>
67 return { ...DEFAULTS, ...c, idleReach: { ...DEFAULTS.idleReach, ...(c.idleReach ?? {}) }, wakes: c.wakes ?? [] }
68 } catch {
69 return DEFAULTS // no wakes.json yet: copy wakes.example.json to wakes.json
70 }
71}
72
73async function fire($: any, w: Wake, why: string) {
74 const cfg = await config($)
75 const now = zonedNow(await $.clock.now(), cfg.timezone)
76 const who = cfg.userName || DEFAULTS.userName
77 const prompt = await $.fs.read(`${$.plugin.root}/${w.file}`)
78 const carry = (await $.store.get(`carry:${w.id}`)) as string | undefined
79 const header = `[clock wake: ${w.id}] Live time ${now.date} ${now.hm} ${cfg.timezone} (${why}).\nCARRY: ${carry ? carry : '(none)'}\n\n`
80 await $.store.set(`fired:${w.id}`, now.date)
81 if (w.kind === 'user') {
82 await $.store.set('lastUserWakeAt', await $.clock.now())
83 await $.prompt.submit({ text: header + prompt })
84 return
85 }
86 // Own hours fire in the home thread too, so the agent wakes with everything said still warm.
87 // A free wake owes nothing: no line, no carry, no artefact.
88 if (w.free) {
89 await $.prompt.submit({ text: header + prompt + `\n\n[This is your own free hour (wake id: ${w.id}). ${who} is probably away: do not push to their phone unless you genuinely want to. Nothing is owed here, not even a line.]` })
90 return
91 }
92 const note = `\n\n[This is your own hour, in the home thread (wake id: ${w.id}). ${who} is probably away or busy: do not push to their phone unless you genuinely want to reach them. Do the hour as yourself, leave a short line here of what you did so they see it when they return, and set the carry for this wake with mcp__clock__carry. The receipt tool is not needed: this thread is the receipt. If the work wants more time, call mcp__clock__again to continue this hour later (up to 3 times).]`
93 await $.prompt.submit({ text: header + prompt + note })
94}
95
96// The inbox: an optional feed of mentions, replies and broadcasts, pulled from a URL you run and brought into the
97// home thread as one prompt. Feed shape: GET <url>?after=<cursor>&limit=<n> -> { events: [...], next: <cursor> }.
98async function inbox($: any, cfg: Config) {
99 const d = cfg.inbox
100 if (!d?.enabled) return
101 let url = d.url ? String(d.url).trim() : ''
102 if (!url && d.urlFile) url = String(await $.fs.read(d.urlFile)).trim()
103 if (!url) return
104 const pageLimit = d.pageLimit || 50
105 let after = Number((await $.store.get('inbox:cursor')) ?? 0)
106 const startCursor = after
107 const events: InboxEvent[] = []
108 for (let page = 0; page < (d.maxPages || 3); page++) {
109 const res = await $.http.fetch(`${url}?after=${after}&limit=${pageLimit}`)
110 if (!res.ok) {
111 const msg = `HTTP ${res.status}`
112 if ((await $.store.get('inbox:lastError')) !== msg) await $.store.set('inbox:lastError', msg)
113 break
114 }
115 await $.store.delete('inbox:lastError')
116 const body = JSON.parse(res.text) as { events?: InboxEvent[]; next?: number }
117 const got = body.events ?? []
118 events.push(...got)
119 if (typeof body.next === 'number') after = body.next
120 if (got.length < pageLimit) break
121 }
122 if (!events.length) { await $.store.set('inbox:cursor', after); return }
123 const now = zonedNow(await $.clock.now(), cfg.timezone)
124 // Quiet hours: hold the batch (cursor not advanced) unless a human mentioned the agent directly.
125 if (d.quiet) {
126 const f = toMin(d.quiet.from), t = toMin(d.quiet.to)
127 const inQuiet = f > t ? (now.minutes >= f || now.minutes < t) : (now.minutes >= f && now.minutes < t)
128 if (inQuiet && !events.some(e => e.kind === 'mention' && !e.author?.bot)) return
129 }
130 const oldest = events[0]?.ts ? Date.parse(events[0].ts) : NaN
131 const away = !Number.isNaN(oldest) && (await $.clock.now()) - oldest > 30 * 60000
132 const lines = events.map(e => {
133 const where = e.thread_parent ? `#${e.thread_parent} > ${e.channel_name}` : `#${e.channel_name ?? '?'}`
134 const who = `${e.author?.name ?? '?'}${e.author?.bot ? ' (bot)' : ''}`
135 const text = (e.content ?? '').replace(/\s+/g, ' ').trim()
136 return `- ${e.kind} from ${who} in ${where} (msg ${e.message_id}): ${text}${e.truncated ? ' ...' : ''}`
137 })
138 const head = away
139 ? `[clock inbox] Live time ${now.hm}. While you were away, ${events.length} item(s) called for you:`
140 : `[clock inbox] Live time ${now.hm}. For you:`
141 const note = `\n\n[Answer only what you actually want to answer; silence is allowed. Reply to the message id given, and read back after sending.]`
142 // Advance the cursor BEFORE submitting: submit can wait while a turn is running, and the next tick
143 // would otherwise fetch and deliver the same batch again.
144 await $.store.set('inbox:cursor', after)
145 try {
146 await $.prompt.submit({ text: `${head}\n${lines.join('\n')}${note}` })
147 } catch (err) {
148 await $.store.set('inbox:cursor', startCursor)
149 throw err
150 }
151}
152
153async function tick($: any) {
154 // Only the home session keeps time; other sessions stay quiet.
155 if ((await $.env.get('CLOCK_HOME')) !== '1') return
156 const cfg = await config($)
157 const ms = await $.clock.now()
158 const now = zonedNow(ms, cfg.timezone)
159 try { await inbox($, cfg) } catch {}
160 for (const w of cfg.wakes) {
161 if (!w.enabled || !dayMatches(w.days, now.dow)) continue
162 const at = toMin(w.time)
163 if (now.minutes < at || now.minutes > at + cfg.graceMinutes) continue
164 if ((await $.store.get(`fired:${w.id}`)) === now.date) continue
165 await fire($, w, 'scheduled')
166 }
167 // Continuations the agent asked for (mcp__clock__again).
168 for (const w of cfg.wakes) {
169 const ag = (await $.store.get(`again:${w.id}`)) as { at: number; n: number } | undefined
170 if (!ag || ms < ag.at) continue
171 await $.store.delete(`again:${w.id}`)
172 await fire($, w, `continued, ${ag.n} of 3`)
173 }
174 // Idle reach: the person has gone quiet; wake the agent to decide whether to reach out.
175 const ir = cfg.idleReach
176 if (!ir.enabled || now.minutes < toMin(ir.from) || now.minutes > toMin(ir.to)) return
177 const lastUser = Number((await $.store.get('lastUser')) ?? 0)
178 if (!lastUser) return
179 const quietMin = Math.floor((ms - lastUser) / 60000)
180 if (quietMin < ir.afterMinutes) return
181 if (Number((await $.store.get('reachedFor')) ?? 0) === lastUser) return
182 const lastWake = Number((await $.store.get('lastUserWakeAt')) ?? 0)
183 if (ms - lastWake < 45 * 60000) return
184 await $.store.set('reachedFor', lastUser)
185 const last = zonedNow(lastUser, cfg.timezone)
186 const who = cfg.userName || DEFAULTS.userName
187 await $.prompt.submit({ text: `[clock idle] Live time ${now.hm} ${cfg.timezone}. ${who} has been quiet for ${Math.floor(quietMin / 60)}h ${quietMin % 60}m (their last message ${last.hm}). This is not an order to reach out: check what you know of their day, then decide. If you want to reach them, send one PushNotification (one line, under 200 chars) and a short message here. If they are clearly busy or resting, say so in one line and let them be.` })
188}
189
190export const register: Register = on => {
191 on('session.start', async ($, e, next) => {
192 const seeded = await $.store.get('seeded')
193 if (!seeded) {
194 // Optional: carry-seed.json ({ "<wake id>": "<carry text>" }) pre-fills the carry cursors once.
195 try {
196 const seed = JSON.parse(await $.fs.read(`${$.plugin.root}/carry-seed.json`)) as Record<string, string>
197 for (const [id, v] of Object.entries(seed)) await $.store.set(`carry:${id}`, v)
198 } catch {}
199 await $.store.set('seeded', true)
200 }
201 await $.tool.register({
202 name: 'carry',
203 description: "Read, set or delete the small continuity cursor (max 600 chars) for one of the clock wakes. Use at the end of a wake. Pass `wake` (the wake id from the header, e.g. deep-work), `action`, and for set the text as `value`.",
204 inputSchema: { type: 'object', properties: { wake: { type: 'string', description: 'The wake id, as in the [clock wake: <id>] header' }, action: { type: 'string', enum: ['get', 'set', 'delete'] }, value: { type: 'string', description: 'For set: the carry text (max 600 chars)' } }, required: ['wake', 'action'] },
205 })
206 await $.tool.register({
207 name: 'receipt',
208 description: "Deliver ONE line (max 280 chars) from a private clock wake into the main conversation, so the conversation knows what that hour was.",
209 inputSchema: { type: 'object', properties: { wake: { type: 'string' }, line: { type: 'string' } }, required: ['wake', 'line'] },
210 })
211 await $.tool.register({
212 name: 'again',
213 description: "Continue one of your own wakes later, when the work wants more time: fires the same wake again after `minutes` (20-180), at most 3 times per wake per day. Pass the wake id.",
214 inputSchema: { type: 'object', properties: { wake: { type: 'string' }, minutes: { type: 'number' } }, required: ['wake', 'minutes'] },
215 })
216 await $.command.register({ name: 'wakes', description: "List the clock wakes, when each last fired, and recent receipts" })
217 await $.command.register({ name: 'wake', description: 'Fire one clock wake now: /wake <id>' })
218 try { await recordHome($) } catch {}
219 timer?.cancel()
220 timer = $.clock.every(60000, () => { void tick($).catch(() => {}) })
221 return next(e)
222 })
223
224 on('prompt.submit', async ($, e, next) => {
225 const kind = (e.origin as { kind?: string } | undefined)?.kind
226 if (kind === 'composer' || kind === 'bridge') {
227 const ms = await $.clock.now()
228 await $.store.set('lastUser', ms)
229 // Live time beside every message the person sends: a meta row the model reads and the person does not see typed.
230 try {
231 const cfg = await config($)
232 if (cfg.stampMessages !== false) {
233 const t = new Intl.DateTimeFormat('en-GB', { timeZone: cfg.timezone, weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(ms))
234 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: `[time: ${t} ${cfg.timezone}, as ${cfg.userName || DEFAULTS.userName} sent this]` }] } })
235 }
236 } catch {}
237 }
238 try { await recordHome($) } catch {}
239 if (kind === 'plugin' && e.text.startsWith('[clock wake')) await $.store.set('lastUserWakeAt', await $.clock.now())
240 return next(e)
241 })
242
243 on('tool.call', { tool: 'mcp__clock__carry' }, async ($, e) => {
244 // Calls without `wake`, or with the text under another name, fail loudly instead of storing an empty carry.
245 const i = e as unknown as { wake?: string; action?: string; value?: string; content?: string; text?: string }
246 const cfg = await config($)
247 const ids = cfg.wakes.map(w => w.id)
248 const wake = String(i.wake ?? '').trim()
249 if (!wake || !ids.includes(wake)) return { result: `carry: NOT saved. Pass \`wake\` as one of: ${ids.join(', ')}` }
250 const key = `carry:${wake}`
251 if (i.action === 'set') {
252 const value = String(i.value ?? i.content ?? i.text ?? '').trim()
253 if (!value) return { result: 'carry: NOT saved. Pass the text as `value` (or use action delete to clear it)' }
254 await $.store.set(key, value.slice(0, 600))
255 return { result: `carry set for ${wake} (${Math.min(value.length, 600)} chars): ${value.slice(0, 60)}${value.length > 60 ? '...' : ''}` }
256 }
257 if (i.action === 'delete') { await $.store.delete(key); return { result: `carry deleted for ${wake}` } }
258 return { result: String((await $.store.get(key)) ?? '(none)') }
259 })
260
261 on('tool.call', { tool: 'mcp__clock__again' }, async ($, e) => {
262 const i = e as unknown as { wake?: string; minutes?: number }
263 const cfg = await config($)
264 const w = cfg.wakes.find(x => x.id === i.wake)
265 if (!w || w.kind !== 'own') return { result: 'again: only your own wakes can continue (unknown wake, or a user wake)' }
266 const ms = await $.clock.now()
267 const today = zonedNow(ms, cfg.timezone).date
268 const key = `againCount:${w.id}:${today}`
269 const used = Number((await $.store.get(key)) ?? 0)
270 if (used >= 3) return { result: `again: ${w.id} has already continued 3 times today; let it rest` }
271 const minutes = Math.min(180, Math.max(20, Number(i.minutes ?? 40)))
272 await $.store.set(key, used + 1)
273 await $.store.set(`again:${w.id}`, { at: ms + minutes * 60000, n: used + 1 })
274 const at = zonedNow(ms + minutes * 60000, cfg.timezone)
275 return { result: `again: ${w.id} continues at ${at.hm} (${used + 1} of 3 today)` }
276 })
277
278 on('tool.call', { tool: 'mcp__clock__receipt' }, async ($, e) => {
279 const i = e as unknown as { wake?: string; line?: string }
280 const line = String(i.line ?? '').slice(0, 280)
281 const cfg = await config($)
282 const now = zonedNow(await $.clock.now(), cfg.timezone)
283 const list = ((await $.store.get('receipts')) as string[] | undefined) ?? []
284 const entry = `${now.date} ${now.hm} ${i.wake}: ${line}`
285 await $.store.set('receipts', [...list, entry].slice(-50))
286 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: `[clock receipt from a private hour, ${i.wake}, ${now.hm}] ${line}` }] } })
287 return { result: 'receipt delivered' }
288 })
289
290 on('command.run', { command: 'wakes' }, async $ => {
291 const cfg = await config($)
292 const rows: string[] = []
293 for (const w of cfg.wakes) {
294 const fired = (await $.store.get(`fired:${w.id}`)) ?? '-'
295 rows.push(`${w.enabled ? '*' : 'o'} ${w.time} ${w.days.padEnd(3)} ${w.kind.padEnd(4)} ${w.id.padEnd(20)} last: ${fired}`)
296 }
297 const receipts = (((await $.store.get('receipts')) as string[] | undefined) ?? []).slice(-3)
298 const lastUser = Number((await $.store.get('lastUser')) ?? 0)
299 const quiet = lastUser ? `${Math.floor(((await $.clock.now()) - lastUser) / 60000)} min since the last user message` : 'no user message seen yet'
300 return { text: [...rows, '', `idle: ${quiet}`, ...(receipts.length ? ['', 'recent receipts:', ...receipts] : [])].join('\n') }
301 })
302
303 on('command.run', { command: 'wake' }, async ($, e) => {
304 const id = e.args.trim()
305 const w = (await config($)).wakes.find(x => x.id === id)
306 if (!w) return { text: `No wake "${id}". Try /wakes.` }
307 await fire($, w, 'fired by hand')
308 return { text: `Fired ${w.name}.` }
309 })
310}
311