SLOPSHOPPER

self-wake

Lets a session wake itself later: a tool sets a wake-up some minutes after the turn ends, and the session then sends itself a note that starts a turn of its…

newguardtoaststatustooltimer
A shopper browsing a rack in a slop shop
README

self-wake

Lets a session wake itself later: some minutes after the turn ends, it sends itself a note that starts a turn of its own. Useful when the next step waits on something slow (a deploy, a long job, a check that reports later) that won't tell the session when it is done. Part of claude-code-self-tools.

Tools

wake_after (Claude sees mcp__self-wake__wake_after):

  • minutes (required): how long after the turn ends, from 1 to 1440 (a day). A number outside that range is brought within it, and the result says so.
  • note (required): what to check or do on waking. The session receives it as "Wake-up: <note>".

cancel_wake (Claude sees mcp__self-wake__cancel_wake), with no arguments: takes back the wake-up, if there is one.

How it behaves

  • The wait starts when the turn ends. The call queues the wake-up, and it is armed once the turn ends with Claude's answer, counting from then. If a turn is running when it fires, the note waits until the session is idle.
  • One wake-up at a time. A new one replaces the one before it, and the result says which.
  • An interrupted turn drops the wake-up it asked for. One armed in an earlier turn stands, and the notice says so.
  • It survives a reload of the plugin. The wake-up is kept in session state; when the plugin's code reloads, it is armed again for the time left, or fires at once if that time has passed. It fires once.
  • It lasts as long as the session. Quitting Claude Code drops it.
  • Subagents can't use it. A subagent is told to say what to check later and when, so the main session can set the wake-up.

Install

/plugin marketplace add moefingers/claude-code-self-tools
/plugin install self-wake@claude-code-self-tools
Source 2 files
hooks/register.ts 166 lines
1// A session wakes itself later: `minutes` after the current turn ends, it sends itself
2// "Wake-up: <note>" as a prompt, a turn of its own. The model calls wake_after; the wake is armed
3// once the turn ends with an answer and counts from there, and an interrupted turn drops it (a
4// wake armed before stands). One wake at a time: a new one replaces the one before it, and says
5// so. cancel_wake takes it back at once. Only the main conversation wakes this way, never a
6// subagent.
7//
8// The armed wake lives in $.state, which survives a reload of this plugin; its timer doesn't. So
9// session.start arms it again for the time left, or fires it at once if that has passed. Firing
10// takes the wake with a compare-and-set on its version, so it is sent once.
11import type { EngineInterface, Register, Timer } from 'claude-code'
12import type { Wake } from '../types'
13
14const queued = { plugin: 'self-wake', key: 'queued' } as const
15const armed = { plugin: 'self-wake', key: 'armed' } as const
16
17const MINUTE = 60 * 1000
18const MIN_MINUTES = 1
19const MAX_MINUTES = 1440
20
21const WAKE_DESCRIPTION = [
22  'Wakes this session later: `minutes` after the current turn ends, the session sends itself "Wake-up: <note>", which starts a turn of its own.',
23  'Call it when the next step has to wait for something that takes time (a deploy, a long job, a check that reports later) and nothing will tell you when it is done, so you can check back without the user prompting you.',
24  'Not for waiting within a turn, and not when a message or notification will arrive on its own.',
25  '`note` is what to check or do on waking, with where its details are.',
26  'One wake-up at a time: a new one replaces the one before. A wake-up lasts as long as the session.',
27  'End the turn after calling it; the wait starts once your answer is shown.',
28].join(' ')
29
30const CANCEL_DESCRIPTION =
31  'Cancels the wake-up set with wake_after, if there is one. Call it when what you were waiting for has arrived or no longer matters.'
32
33const SUBAGENT =
34  "A subagent can't set or cancel the session's wake-ups. Finish your piece and return its result, saying what to check later and when: the main session can set the wake-up."
35
36/** This module's timer for the armed wake; a reload drops it, and session.start arms a new one. */
37let timer: Timer | undefined
38
39/** Trimmed text, or undefined when it is not a string or is blank. */
40function text(raw: unknown): string | undefined {
41  const trimmed = typeof raw === 'string' ? raw.trim() : ''
42  return trimmed === '' ? undefined : trimmed
43}
44
45const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? '' : 's'}`
46
47/** How long until `dueAt`, in whole minutes, at least one. */
48const dueIn = (dueAt: number, now: number): string => plural(Math.max(1, Math.ceil((dueAt - now) / MINUTE)), 'minute')
49
50/** The status line while a wake is armed, its note cut short. */
51const pinned = (note: string): string => `wake-up pending: ${note.length > 60 ? `${note.slice(0, 59)}…` : note}`
52
53async function fire($: EngineInterface, id: string): Promise<void> {
54  const held = await $.state.get(armed)
55  if (held.value?.id !== id) return
56  const taken = await $.state.set(armed, null, { ifVersion: held.version })
57  if (!taken.isSet) return
58  $.ui.status(undefined)
59  try {
60    const sent = await $.prompt.submit({ text: `Wake-up: ${held.value.note}` })
61    if (sent.drop !== undefined) $.ui.toast(`Wake-up not sent: ${sent.drop}`)
62  } catch {
63    $.ui.toast('Wake-up not sent; the session waits for the next message.')
64  }
65}
66
67/** Starts this module's timer for `wake`, in place of any it had; one already due fires at once. */
68function arm($: EngineInterface, wake: Wake, now: number): void {
69  timer?.cancel()
70  timer = $.clock.after(Math.max(0, wake.dueAt - now), () => void fire($, wake.id))
71  $.ui.status(pinned(wake.note))
72}
73
74export const register: Register = on => {
75  on('session.start', async ($, e, next) => {
76    const started = await next(e)
77    await $.tool.register({
78      name: 'wake_after',
79      description: WAKE_DESCRIPTION,
80      inputSchema: {
81        type: 'object',
82        properties: {
83          minutes: {
84            type: 'number',
85            description: 'How long after this turn ends, in minutes: 1 to 1440 (a day); a number outside that is brought within it.',
86          },
87          note: { type: 'string', description: 'What to check or do on waking, and where its details are.' },
88        },
89        required: ['minutes', 'note'],
90        additionalProperties: false,
91      },
92      isDeferred: false,
93    })
94    await $.tool.register({
95      name: 'cancel_wake',
96      description: CANCEL_DESCRIPTION,
97      inputSchema: { type: 'object', properties: {}, additionalProperties: false },
98      isDeferred: false,
99    })
100    // After a reload: the wake is still in $.state, its timer gone.
101    const { value } = await $.state.get(armed)
102    if (value) arm($, value, await $.clock.now())
103    return started
104  })
105
106  on('tool.call', { tool: 'mcp__self-wake__wake_after' }, async ($, e) => {
107    if (e.agentId !== undefined) return { deny: SUBAGENT }
108    const input = e as { minutes?: unknown; note?: unknown }
109    const asked = typeof input.minutes === 'number' ? input.minutes : Number(text(input.minutes) ?? Number.NaN)
110    if (!Number.isFinite(asked)) return { deny: '`minutes` is a number from 1 to 1440.' }
111    const note = text(input.note)
112    if (note === undefined) return { deny: 'Say what to wake up to (`note`).' }
113    const minutes = Math.min(MAX_MINUTES, Math.max(MIN_MINUTES, asked))
114    const [before, earlier, now] = await Promise.all([$.state.get(queued), $.state.get(armed), $.clock.now()])
115    await $.state.set(queued, { minutes, note })
116    $.ui.status(`wake-up queued for when this turn ends: ${note.length > 40 ? `${note.slice(0, 39)}…` : note}`)
117    const clamped = minutes === asked ? '' : ` (${asked} is outside 1 to 1440)`
118    const replaces = before.value
119      ? ` It replaces the wake-up queued earlier this turn ("${before.value.note}").`
120      : earlier.value
121        ? ` It replaces the wake-up due in ${dueIn(earlier.value.dueAt, now)} ("${earlier.value.note}").`
122        : ''
123    return {
124      result: `Queued: ${plural(minutes, 'minute')} after this turn ends${clamped}, the session sends itself "Wake-up: ${note}".${replaces}`,
125    }
126  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'self-wake: queuing the wake-up failed; nothing is queued.' }))
127
128  on('tool.call', { tool: 'mcp__self-wake__cancel_wake' }, async ($, e) => {
129    if (e.agentId !== undefined) return { deny: SUBAGENT }
130    const [before, earlier, now] = await Promise.all([$.state.get(queued), $.state.get(armed), $.clock.now()])
131    await $.state.set(queued, null)
132    await $.state.set(armed, null)
133    timer?.cancel()
134    timer = undefined
135    $.ui.status(undefined)
136    const gone = [
137      ...(earlier.value ? [`the wake-up due in ${dueIn(earlier.value.dueAt, now)} ("${earlier.value.note}")`] : []),
138      ...(before.value ? [`the wake-up queued this turn ("${before.value.note}")`] : []),
139    ]
140    return { result: gone.length === 0 ? 'No wake-up was set; nothing to cancel.' : `Cancelled ${gone.join(' and ')}.` }
141  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'self-wake: cancelling failed; a wake-up may still be set.' }))
142
143  on('turn.complete', async ($, e, next) => {
144    const done = await next(e)
145    if (e.agentId !== undefined) return done
146    const { value } = await $.state.get(queued)
147    if (!value) return done
148    await $.state.set(queued, null)
149    if (e.isAborted || e.reason !== 'answer') {
150      const { value: standing } = await $.state.get(armed)
151      $.ui.status(standing ? pinned(standing.note) : undefined)
152      $.ui.toast(`Wake-up not set: the turn didn't finish.${standing ? ' The one set before stands.' : ''}`)
153      return done
154    }
155    const now = await $.clock.now()
156    const wake: Wake = {
157      id: `${now}-${Math.random().toString(36).slice(2, 10)}`,
158      dueAt: now + value.minutes * MINUTE,
159      note: value.note,
160    }
161    await $.state.set(armed, wake)
162    arm($, wake, now)
163    return done
164  })
165}
166
types/index.d.ts 12 lines
1/** A wake-up asked for this turn, armed once the turn ends with an answer: how long after, and its note. */
2export type QueuedWake = { minutes: number; note: string }
3
4/** A wake-up armed: its id, when it is due in `$.clock.now()` milliseconds, and the note it sends. */
5export type Wake = { id: string; dueAt: number; note: string }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'self-wake': { queued: QueuedWake | null; armed: Wake | null }
10  }
11}
12