In-process wakeups and recurring prompts: /tick and a wake tool, no routine quota

hooks/register.ts 145 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Job } from '../types'
5
6// Wakes this session from inside the process: $.clock + $.prompt.submit.
7// Unlike ScheduleWakeup/CronCreate it spends none of the ~15 routines/24h
8// account quota, and it dies with the process (no cloud, no resume).
9
10const jobs = atom({ plugin: 'tick', key: 'jobs' } as const, [])
11const seq = atom({ plugin: 'tick', key: 'seq' } as const, 0)
12
13const POLL_MS = 15_000
14const MIN_MS = 60_000
15const MAX_RECURRING_FIRES = 48
16
17type $ = EngineInterface
18
19export function parseDuration(s: string): number | null {
20 const m = /^(\d+(?:\.\d+)?)(s|m|h)$/.exec(s.trim())
21 if (!m) return null
22 const unit = { s: 1_000, m: 60_000, h: 3_600_000 }[m[2] as 's' | 'm' | 'h']
23 return Math.round(Number(m[1]) * unit)
24}
25
26const fmt = (ms: number) => (ms >= 3_600_000 ? `${(ms / 3_600_000).toFixed(1)}h` : `${Math.max(0, Math.round(ms / 60_000))}m`)
27
28async function schedule($: $, prompt: string, afterMs: number, every: boolean): Promise<Job> {
29 const id = `t${await update($, seq, n => n + 1)}`
30 const job: Job = {
31 id,
32 prompt,
33 everyMs: every ? afterMs : null,
34 dueAt: (await $.clock.now()) + afterMs,
35 fired: 0,
36 maxFires: every ? MAX_RECURRING_FIRES : 1,
37 }
38 await update($, jobs, list => [...list, job])
39 return job
40}
41
42async function describe($: $): Promise<string> {
43 const now = await $.clock.now()
44 const list = await read($, jobs)
45 if (list.length === 0) return 'No ticks scheduled.'
46 return list
47 .map(j => `${j.id} in ${fmt(j.dueAt - now)}${j.everyMs ? `, every ${fmt(j.everyMs)} (${j.fired}/${j.maxFires})` : ''} ${j.prompt.slice(0, 70)}`)
48 .join('\n')
49}
50
51// turnId → start time; a turn whose complete never arrives stops blocking after STUCK_MS.
52const running = new Map<string, number>()
53const STUCK_MS = 2 * 3_600_000
54let isSubmitting = false
55
56async function poll($: $): Promise<void> {
57 const now = await $.clock.now()
58 const list = await read($, jobs)
59 $.ui.status(list.length === 0 ? undefined : `tick: ${list.length} · next ${fmt(Math.min(...list.map(j => j.dueAt)) - now)}`)
60 for (const [id, at] of running) if (now - at > STUCK_MS) running.delete(id)
61 if (isSubmitting || running.size > 0) return
62 const due = list.filter(j => j.dueAt <= now).sort((a, b) => a.dueAt - b.dueAt)[0]
63 if (due === undefined) return
64 const fired = due.fired + 1
65 await update($, jobs, all =>
66 due.everyMs === null || fired >= due.maxFires
67 ? all.filter(j => j.id !== due.id)
68 : all.map(j => (j.id === due.id ? { ...j, fired, dueAt: now + (due.everyMs ?? 0) } : j)),
69 )
70 isSubmitting = true
71 try {
72 await $.prompt.submit({ text: `[tick ${due.id}${due.everyMs ? ` ${fired}/${due.maxFires}` : ''}] ${due.prompt}` })
73 } finally {
74 isSubmitting = false
75 }
76}
77
78export const register: Register = on => {
79 on('session.start', async ($, e, next) => {
80 await $.command.register({
81 name: 'tick',
82 description: 'Schedule a local prompt: /tick 20m <prompt> · /tick every 30m <prompt> · /tick stop [id] · /tick',
83 argumentHint: '[every] <30s|20m|2h> <prompt> | stop [id]',
84 })
85 await $.tool.register({
86 name: 'wake',
87 description:
88 'Wake this session later with a prompt, from inside this Claude Code process. Prefer it over ScheduleWakeup/CronCreate for waits inside a live session: it uses none of the account routine quota. It does not survive the process exiting. Minimum 60s; recurring wakes stop after 48 fires. `cancel` with an id removes one.',
89 inputSchema: {
90 type: 'object',
91 properties: {
92 seconds: { type: 'integer', minimum: 60, description: 'Delay before the wake (and the period when every is true).' },
93 prompt: { type: 'string', description: 'The prompt the session receives on waking.' },
94 every: { type: 'boolean', description: 'Repeat at this period.' },
95 cancel: { type: 'string', description: 'Id of a scheduled wake to cancel; other fields ignored.' },
96 },
97 },
98 })
99 $.clock.every(POLL_MS, () => void poll($))
100 return next(e)
101 })
102
103 on('turn.start', async ($, e, next) => {
104 running.set(e.turnId, await $.clock.now())
105 return next(e)
106 })
107
108 on('turn.complete', ($, e, next) => {
109 running.delete(e.turnId)
110 return next(e)
111 })
112
113 on('tool.call', { tool: 'mcp__tick__wake' }, async ($, e) => {
114 const input = e as unknown as { seconds?: number; prompt?: string; every?: boolean; cancel?: string }
115 if (input.cancel) {
116 const before = (await read($, jobs)).length
117 await update($, jobs, list => list.filter(j => j.id !== input.cancel))
118 const removed = before - (await read($, jobs)).length
119 return { result: removed ? `Cancelled ${input.cancel}.` : `No wake named ${input.cancel}.` }
120 }
121 if (!input.prompt || !input.seconds || input.seconds < 60) {
122 return { result: 'Needs prompt and seconds >= 60.', isError: true }
123 }
124 const job = await schedule($, input.prompt, input.seconds * 1000, input.every === true)
125 return { result: `Scheduled ${job.id} in ${fmt(input.seconds * 1000)}${job.everyMs ? ', recurring' : ''}.\n${await describe($)}` }
126 })
127
128 on('command.run', { command: 'tick' }, async ($, e) => {
129 const args = e.args.trim()
130 if (args === '') return { text: await describe($) }
131 const stop = /^stop(?:\s+(\S+))?$/.exec(args)
132 if (stop) {
133 await update($, jobs, list => (stop[1] ? list.filter(j => j.id !== stop[1]) : []))
134 return { text: await describe($) }
135 }
136 const m = /^(every\s+)?(\S+)\s+([\s\S]+)$/.exec(args)
137 const ms = m ? parseDuration(m[2] ?? '') : null
138 if (!m || ms === null || ms < MIN_MS) {
139 return { text: 'Usage: /tick [every] <60s|20m|2h> <prompt> · /tick stop [id] · /tick' }
140 }
141 const job = await schedule($, (m[3] ?? '').trim(), ms, m[1] !== undefined)
142 return { text: `Scheduled ${job.id}.\n${await describe($)}` }
143 })
144}
145types/index.d.ts 15 lines1export type Job = {
2 id: string
3 prompt: string
4 everyMs: number | null
5 dueAt: number
6 fired: number
7 maxFires: number
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 tick: { jobs: Job[]; seq: number }
13 }
14}
15