SLOPSHOPPER

self-reload

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…

newguardtoaststatustooltimer
A shopper browsing a rack in a slop shop
README

self-reload

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.

Tool

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.

How it behaves

  • The reload waits for the turn to end. The call queues it; /reload-plugins runs once the turn ends with Claude's answer, and shows in the transcript as if you had typed it.
  • With resume, the session carries on: it sends itself "Plugins reloaded. Resume: …", which starts a new turn. Without resume, it waits for your next message.
  • Sent once, through its own reload. The reload replaces this plugin's code too, so the 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.
  • A refused or skipped reload is reported. If Claude Code refuses to run /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.
  • An interrupted turn cancels it, with a notice.
  • Subagents can't use it. A subagent that calls the tool is told to finish its piece and say a reload is needed.

Install

/plugin marketplace add moefingers/claude-code-self-tools
/plugin install self-reload@claude-code-self-tools
Source 2 files
hooks/register.ts 175 lines
1// 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}
175
types/index.d.ts 18 lines
1/**
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