SLOPSHOPPER

tick

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

newguardcommandstatustooltimer
★ 1v0.1.0no licenseupdated 2026-10-04markusstrasser/agent-infra/mods/tick
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tick
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /tick ⎿ tick: No ticks scheduled. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
Source 2 files
hooks/register.ts 145 lines
1import { 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}
145
types/index.d.ts 15 lines
1export 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