Lets a session reload its own plugins once the current turn ends, as /reload-plugins does, then resume on its own: a tool queues the reload, and it runs as the…

Lets a session reload its own plugins once the current turn ends, as /reload-plugins does, then carry on by itself. Useful when the session edits a plugin and needs the change live, for example to try a tool it just changed. Part of claude-code-self-tools.
reload_plugins_after_turn (Claude sees mcp__self-reload__reload_plugins_after_turn):
resume (optional): the message that starts the session's next turn once the plugins have reloaded, saying what to check or do next./reload-plugins runs once the turn ends with Claude's answer, and shows in the transcript as if you had typed it.resume, the session carries on: it sends itself "Plugins reloaded. Resume: …", which starts a new turn. Without resume, it waits for your next message.resume is written to session state before the command runs. Whichever copy of the plugin is running when the reload is done sends it, once. A resume that no reload picks up within ten minutes is dropped, with a notice, rather than sent late./reload-plugins for a plugin, or the command skips the reload (it does when the reload would change which MCP tools are loaded, until it is run with --force, which this plugin leaves to you), a toast says so, and the resume, if there is one, is sent saying the reload didn't happen. Without a resume, Claude is told on its next turn./plugin marketplace add moefingers/claude-code-self-tools
/plugin install self-reload@claude-code-self-toolshooks/register.ts 175 lines1// A session reloads its own plugins once the current turn ends, as /reload-plugins does, and can
2// carry on by itself afterwards. The model calls reload_plugins_after_turn; a command can't run
3// while the turn the model is in waits on it, so the call is queued and the reload runs once the
4// turn has ended with an answer. An interrupted turn cancels it. Only the main conversation reloads
5// this way, never a subagent.
6//
7// The reload replaces this plugin's own module, and with it every module variable and timer; only
8// $.state survives. So the resume is written to $.state before the command runs, and whichever
9// comes first sends it, once: session.start in the module the reload started, or this module when
10// the command returns and it is still running (the reload left it in place, or failed). Each takes
11// it with a compare-and-set on its version, so the two can't both send it.
12import type { EngineInterface, Register } from 'claude-code'
13import type { Pending, Queued } from '../types'
14
15const queued = { plugin: 'self-reload', key: 'queued' } as const
16const pending = { plugin: 'self-reload', key: 'pending' } as const
17
18/** A beat after the turn's end, so the turn is no longer running when the reload starts. */
19const SETTLE_MS = 500
20
21/** A pending resume older than this is dropped, not sent: the turn that asked for it is long gone. */
22const STALE_MS = 10 * 60 * 1000
23
24/**
25 * How /reload-plugins says it skipped the reload: it does when the reload would change which MCP
26 * tools are loaded, until it is run with --force, which this plugin leaves to the user.
27 */
28const SKIPPED = /\/reload-plugins --force/
29
30const DESCRIPTION = [
31 "Reloads this session's plugins once the current turn ends, as /reload-plugins does, so a plugin change made in this session takes effect without a restart.",
32 'Call it after you have changed plugin files (a hooks module, a skill, a manifest) and the next step needs the change live, such as trying a tool you just edited.',
33 'Not mid-edit: finish the change first. Not for a change that needs a new session anyway.',
34 '`resume` is the message that starts your next turn once the plugins have reloaded: what to check or do next.',
35 'Give it whenever you have a next step of your own; leave it out only when the next step waits on someone else (the user, another session, a check).',
36 'End the turn after calling it; the reload runs once your answer is shown.',
37].join(' ')
38
39/** What is queued: `resume` only when there is one, since stored state holds no undefined. */
40const entry = (resume: string | undefined): Queued => (resume === undefined ? {} : { resume })
41
42/** Trimmed text, or undefined when it is not a string or is blank. */
43function text(raw: unknown): string | undefined {
44 const trimmed = typeof raw === 'string' ? raw.trim() : ''
45 return trimmed === '' ? undefined : trimmed
46}
47
48/** Why a call was refused, as one line. */
49function why(err: unknown): string {
50 const said = (err instanceof Error ? err.message : String(err)).trim()
51 return said === '' ? 'no reason given' : said
52}
53
54/** Tells the model on its next turn, in a row it reads and the person doesn't see as typed. */
55async function tellModel($: EngineInterface, note: string): Promise<void> {
56 try {
57 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
58 } catch {
59 // The toast has told the person; there is no other way to reach the model.
60 }
61}
62
63/** Takes the pending resume, if it is still there and no one took it first. */
64async function claim($: EngineInterface): Promise<Pending | undefined> {
65 const held = await $.state.get(pending)
66 if (!held.value) return undefined
67 const taken = await $.state.set(pending, null, { ifVersion: held.version })
68 return taken.isSet ? held.value : undefined
69}
70
71async function sendResume($: EngineInterface, said: string, resume: string): Promise<void> {
72 try {
73 const sent = await $.prompt.submit({ text: `${said} Resume: ${resume}` })
74 if (sent.drop !== undefined) $.ui.toast(`Resume not sent: ${sent.drop}`)
75 } catch {
76 $.ui.toast('Resume not sent; the session waits for the next message.')
77 }
78}
79
80/** Runs the reload; then, if this module is still the one running, sends what is pending. */
81async function reloadNow($: EngineInterface): Promise<void> {
82 $.ui.status(undefined)
83 let failed: string | undefined
84 let output: string | undefined
85 try {
86 output = text((await $.command.run({ command: 'reload-plugins' })).text)
87 } catch (err) {
88 failed = why(err)
89 }
90 const value = await claim($)
91 if (value === undefined) return
92 if (failed !== undefined) {
93 $.ui.toast(`Plugin reload didn't run: ${failed}`)
94 if (value.resume !== undefined) await sendResume($, `Plugin reload didn't run (${failed}).`, value.resume)
95 else await tellModel($, `The plugin reload you queued didn't run: ${failed}`)
96 return
97 }
98 if (output !== undefined && SKIPPED.test(output)) {
99 $.ui.toast(`Plugin reload skipped: ${output}`)
100 if (value.resume !== undefined) await sendResume($, `Plugin reload was skipped (${output}).`, value.resume)
101 else await tellModel($, `The plugin reload you queued was skipped: ${output}`)
102 return
103 }
104 $.ui.toast(output === undefined ? 'Plugins reloaded.' : `Plugins reloaded. ${output}`)
105 if (value.resume !== undefined) await sendResume($, 'Plugins reloaded.', value.resume)
106}
107
108/** At a module's start: after a reload this session asked for, send its resume, once. */
109async function resumeAfterReload($: EngineInterface): Promise<void> {
110 const value = await claim($)
111 if (value === undefined) return
112 $.ui.status(undefined)
113 if ((await $.clock.now()) - value.at > STALE_MS) {
114 $.ui.toast('A queued resume was dropped: its plugin reload never reported back.')
115 return
116 }
117 $.ui.toast('Plugins reloaded.')
118 // Not awaited: a submitted prompt starts its turn only once this start has finished.
119 if (value.resume !== undefined) void sendResume($, 'Plugins reloaded.', value.resume)
120}
121
122export const register: Register = on => {
123 on('session.start', async ($, e, next) => {
124 const started = await next(e)
125 await $.tool.register({
126 name: 'reload_plugins_after_turn',
127 description: DESCRIPTION,
128 inputSchema: {
129 type: 'object',
130 properties: {
131 resume: {
132 type: 'string',
133 description:
134 'The message that starts your next turn once the plugins have reloaded: what to check or do next. Leave it out only when the next step waits on someone else.',
135 },
136 },
137 additionalProperties: false,
138 },
139 isDeferred: false,
140 })
141 await resumeAfterReload($)
142 return started
143 })
144
145 on('tool.call', { tool: 'mcp__self-reload__reload_plugins_after_turn' }, async ($, e) => {
146 if (e.agentId !== undefined) {
147 return {
148 deny: "A subagent can't reload the session's plugins. Finish your piece and return its result, saying a reload is needed: the main session can reload.",
149 }
150 }
151 const resume = text((e as { resume?: unknown }).resume)
152 await $.state.set(queued, entry(resume))
153 $.ui.status('plugin reload queued for when this turn ends')
154 const then = resume === undefined ? 'then waits for the next message' : `then resumes with: ${resume}`
155 return { result: `Queued. Plugins reload once this turn ends, and the session ${then}` }
156 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'self-reload: queuing the reload failed; nothing is queued.' }))
157
158 on('turn.complete', async ($, e, next) => {
159 const done = await next(e)
160 if (e.agentId !== undefined) return done
161 const { value } = await $.state.get(queued)
162 if (!value) return done
163 await $.state.set(queued, null)
164 if (e.isAborted || e.reason !== 'answer') {
165 $.ui.status(undefined)
166 $.ui.toast("Plugin reload cancelled: the turn didn't finish.")
167 return done
168 }
169 // Written before the reload replaces this module, so the module it starts finds it.
170 await $.state.set(pending, { ...value, at: await $.clock.now() })
171 $.clock.after(SETTLE_MS, () => void reloadNow($))
172 return done
173 })
174}
175types/index.d.ts 18 lines1/**
2 * A reload the session asked for, waiting for its turn to end: the message it resumes with once the
3 * plugins have reloaded (absent: it waits for the next message).
4 */
5export type Queued = { resume?: string }
6
7/**
8 * A reload under way, written before the command runs so that the module the reload starts finds
9 * it: what to resume with, and `at`, when it was written, in `$.clock.now()` milliseconds.
10 */
11export type Pending = Queued & { at: number }
12
13declare module 'claude-code' {
14 interface PluginState {
15 'self-reload': { queued: Queued | null; pending: Pending | null }
16 }
17}
18