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…

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.
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.
/plugin marketplace add moefingers/claude-code-self-tools
/plugin install self-wake@claude-code-self-toolshooks/register.ts 166 lines1// 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}
166types/index.d.ts 12 lines1/** 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