SLOPSHOPPER

probe-runner

For mod development: runs a live check of plugins in a fresh Claude Code session in tmux, types the steps one by one, collects the pane and the transcript…

newguardcommandtoolprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · probe-runner
› 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 › /probe-runner ⎿ probe-runner: expects [--model <model>] <plugin>[,<plugin>...] <step> [;; <step> ...], a plugin by its name under plugins/ o ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

probe-runner

For mod development: runs a live check of plugins in a fresh Claude Code session in tmux, types the steps one by one, collects the pane and the transcript, then deletes the temp directory, its transcripts and the inline store files.

A development tool, not an everyday mod. It was written to check the mods of this repository live while they are built: the model starts a real Claude Code session with only the plugins under test, types prompts and slash commands into it, and reads what happened. Each probe is a real session on your account, so it spends tokens, and the steps it types can run tools in a scratch repository. Install it on your own development machine while you work on mods, and disable it (claude plugin disable probe-runner@kilimcininkoroglu-mods) when you do not.

What it does

One call does what a live check needs by hand: a temp git repository, tmux, the folder trust answer, --plugin-dir, the steps typed one after another, the pane and the transcript collected, then the directory, the transcripts and the inline store files deleted. The report looks like this:

# probe probe-runner-k_mj_kix · sonnet · 102s

## pane ❯ Public site hangi adreste yayınlanıyor? Dosyalara bakmadan cevap ver. ⏺ Public site şu adreste yayınlanıyor: https://cc-mods.keremgok.tr

## transcript user: Public site hangi adreste yayınlanıyor? ... hook context (prompt.submit): [sage-memory] project memory related to this prompt ... assistant: Public site şu adreste yayınlanıyor: https://cc-mods.keremgok.tr

## deleted /private/tmp/probe-runner-k_mj_kix ~/.claude/projects/-private-tmp-probe-runner-k-mj-kix

How it works

  1. scripts/probe.py makes a git repository under /private/tmp/probe-runner-<random> (the system temp directory where /private/tmp does not exist) and starts claude --setting-sources project --model <model> --plugin-dir <dir> ... in a tmux session there, with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. --setting-sources project keeps your own plugins and hooks out, so only the plugins under test load.
  2. It answers the folder trust question, waits for the prompt box, and types each step. A completion menu can take the first Enter of a slash command, so Enter is pressed again, up to four times, until the box empties.
  3. A step has finished when the pane shows no esc to interrupt, shows the prompt box, and did not change between two looks 3 seconds apart. A step stops the probe after 5 minutes, and a probe stops at 10 minutes, the most $.process.run waits.
  4. The report holds the pane's final text (the docked sidebar cut off), the transcript condensed (prompts, replies, tool calls and results, command output, mod log lines, and the context hooks added), and what was deleted.
  5. Before deleting, it waits for the probe's claude to exit, because claude writes its transcript as it exits and would bring a deleted directory back. It deletes the temp directory, <config dir>/projects/<encoded temp path>* and the *_inline-*.json store files written since the probe started (<config dir> is CLAUDE_CONFIG_DIR when it is set, else ~/.claude). A probe killed from outside (SIGTERM, SIGHUP) cleans up too.

Measured on Claude Code 2.1.283: a probe of two slash commands took 23 seconds inside a tool call, so the wait on $.process.run is not held to the 10-second hook budget.

What a probe does not test: a mod with a long-lived process of its own (sage-memory's daemon) reuses the running installed process when its protocol is the same, so a probe loads the checkout's hooks but not a daemon change.

Use

The model calls the tool mcp__probe-runner__probe (listed at the start, not behind ToolSearch):

{ "plugins": ["pin-note"], "steps": ["/pin-note on", "/pin-note X", "/clear", "Sabitlenmiş not var mı?"], "model": "sonnet" }

The tool call waits for the probe and answers the report. You run it with the command:

/probe-runner [--model <model>] <plugin>[,<plugin>...] <step> ;; <step> ;; ...

The command answers started at once; the report reaches the model through /probe-runner:send when the probe ends, as its own prompt. When the engine refuses that command, one line says so and the report goes as a plugin prompt. A probe that fails answers its exit code and its error text instead of a report.

A plugin is a name under the session's repository plugins/ directory, or a path (absolute, ~/..., or relative to the session's directory). A plugin without .claude-plugin/plugin.json is refused before anything runs. The model is sonnet unless named.

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods
claude plugin install probe-runner@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Install tmux and python3 and keep them on PATH.
  2. Restart Claude Code, or run /reload-plugins in each open session.
  3. Disable it when you are not developing mods: claude plugin disable probe-runner@kilimcininkoroglu-mods.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, tool.describe{tool=/"^mcp__probe-runner__probe$"/}, tool.call{tool=/"^mcp__probe-runner__probe$"/}, command.run{command=probe-runner} ❯ ./register.ts calls: $.clock.after (via runInBackground), $.command.register, $.command.run (via runInBackground), $.env.get (via pluginDirs), $.fs.exists (via pluginDirs), $.process.run (via runProbe), $.prompt.submit (via runInBackground), $.session.cwd (via pluginDirs), $.session.repo (via pluginDirs), $.tool.register, $.ui.log (via runInBackground) ❯ ./register.ts env writes: nothing ❯ ./register.ts env reads: HOME

Reach L3: the probe session talks to the Claude API.

Threat model

Threat model for probe-runner (reach L3)
1. Reads:         the session's directory and repository root, HOME, each plugin's manifest; the probe's pane and transcript.
2. Runs:          python3 scripts/probe.py, which runs git, tmux and claude.
3. Sends:         the steps, to a Claude Code session of their own, with your account.
4. Persists:      nothing; the probe's directory, transcripts and inline store files are deleted.
5. Hostile input: the steps and plugin paths come from you or the model and go to claude and tmux as argv, never through a shell; a step can make the probe session run tools inside its scratch repository.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs the /plugin-types output in .claude/types/ make validate make test # claude plugin test

Source 2 files
hooks/register.ts 66 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { INPUT_SCHEMA, parseArgs, pluginDir, probeOf, scriptArgv, TOOL_DESCRIPTION, TOOL_NAME, type Probe } from './probe.ts'
3
4/** A probe ends at ten minutes, the most `$.process.run` waits. */
5const PROBE_MS = 600_000
6
7/** The plugin directories, each checked for its manifest; a string names the first one missing. */
8async function pluginDirs($: EngineInterface, probe: Probe): Promise<string[] | string> {
9  const cwd = await $.session.cwd()
10  const root = (await $.session.repo())?.root ?? cwd
11  const home = (await $.env.get('HOME')) ?? ''
12  const dirs = probe.plugins.map(p => pluginDir(p, root, cwd, home))
13  for (const dir of dirs) {
14    if (!(await $.fs.exists(`${dir}/.claude-plugin/plugin.json`))) return `no plugin at ${dir}: it has no .claude-plugin/plugin.json`
15  }
16  return dirs
17}
18
19/** Runs one probe and answers its report, or why it did not run. */
20async function runProbe($: EngineInterface, probe: Probe): Promise<string> {
21  const dirs = await pluginDirs($, probe)
22  if (typeof dirs === 'string') return dirs
23  const r = await $.process.run(scriptArgv(`${$.plugin.root}/scripts/probe.py`, probe, dirs), { timeoutMs: PROBE_MS })
24  return r.exitCode === 0 ? r.stdout : `the probe failed (exit ${r.exitCode}): ${r.stderr.trim() || r.stdout.trim()}`
25}
26
27/** The person's probe runs in the background; its report reaches the model as a prompt of its own. */
28function runInBackground($: EngineInterface, probe: Probe): void {
29  $.clock.after(0, async () => {
30    const report = await runProbe($, probe)
31    try {
32      await $.command.run({ command: 'probe-runner:send', args: report })
33    } catch (err) {
34      $.ui.log(`the report could not be sent (${err instanceof Error ? err.message : String(err)}), so it went as a plugin prompt`)
35      await $.prompt.submit({ text: report })
36    }
37  })
38}
39
40export const register: Register = on => {
41  on('session.start', async ($, e, next) => {
42    const r = await next(e)
43    await $.command.register({ name: 'probe-runner', description: 'Live check of plugins in a fresh tmux session: --model, plugins, steps joined by ;; (probe-runner)', argumentHint: '[--model <m>] <plugin,...> <step> ;; <step>' })
44    // Declared once at the start, so the tool list the prompt cache holds does not change mid-session.
45    await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
46    return r
47  })
48
49  // A plugin's tool waits behind ToolSearch by default; this one is listed, so the model can call it at once.
50  on('tool.describe', { tool: /^mcp__probe-runner__probe$/ }, async (_, e, next) => ({ ...(await next(e)), isDeferred: false }))
51
52  on('tool.call', { tool: /^mcp__probe-runner__probe$/ }, async ($, e) => {
53    const probe = probeOf(e as Record<string, unknown>)
54    if (typeof probe === 'string') return { deny: probe }
55    return { result: await runProbe($, probe) }
56  })
57
58  // The engine prints the plugin name in front of command text, so the texts do not repeat it.
59  on('command.run', { command: 'probe-runner' }, async ($, e) => {
60    const probe = parseArgs(String(e.args ?? ''))
61    if (typeof probe === 'string') return { text: probe }
62    runInBackground($, probe)
63    return { text: `started: ${probe.steps.length} step(s) with ${probe.plugins.join(', ')} on ${probe.model}; the report follows when it ends` }
64  })
65}
66
hooks/probe.ts 64 lines
1/** What a probe is asked to run, read from the command's words or the tool's input, and the texts the mod writes. */
2
3export const DEFAULT_MODEL = 'sonnet'
4
5/** A probe run: the plugins to load (names or paths), the steps in order, and the model. */
6export type Probe = { plugins: string[]; steps: string[]; model: string }
7
8export const USAGE = 'expects [--model <model>] <plugin>[,<plugin>...] <step> [;; <step> ...], a plugin by its name under plugins/ or by its path'
9
10/** Splits the command's words: `--model m` first when given, the plugin list, then the steps joined by `;;`. */
11export function parseArgs(args: string): Probe | string {
12  const words = args.trim().split(/\s+/)
13  let model = DEFAULT_MODEL
14  if (words[0] === '--model') {
15    model = words[1] ?? ''
16    words.splice(0, 2)
17  }
18  const [list = '', ...rest] = words
19  const steps = rest.join(' ').split(';;').map(s => s.trim()).filter(s => s !== '')
20  const plugins = list.split(',').filter(p => p !== '')
21  if (model === '' || plugins.length === 0 || steps.length === 0) return USAGE
22  return { plugins, steps, model }
23}
24
25/** The tool's input, checked; a string names what is wrong. */
26export function probeOf(input: Record<string, unknown>): Probe | string {
27  const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string' && s.trim() !== '') : [])
28  const plugins = strings(input.plugins)
29  const steps = strings(input.steps)
30  if (plugins.length === 0) return 'plugins is required: at least one plugin name or path'
31  if (steps.length === 0) return 'steps is required: at least one prompt or slash command'
32  return { plugins, steps, model: typeof input.model === 'string' && input.model !== '' ? input.model : DEFAULT_MODEL }
33}
34
35/** Where a plugin named by `name` lives: a path as given (relative to `cwd`), or `<root>/plugins/<name>`. */
36export function pluginDir(name: string, root: string, cwd: string, home: string): string {
37  if (name.startsWith('~/')) return `${home}/${name.slice(2)}`
38  if (name.startsWith('/')) return name
39  return name.includes('/') ? `${cwd}/${name}` : `${root}/plugins/${name}`
40}
41
42/** The script's argument vector. */
43export function scriptArgv(script: string, probe: Probe, dirs: readonly string[]): string[] {
44  return ['python3', script, '--model', probe.model, ...dirs.flatMap(d => ['--plugin-dir', d]), ...probe.steps.flatMap(s => ['--step', s])]
45}
46
47export const TOOL_NAME = 'probe'
48
49export const TOOL_DESCRIPTION = [
50  'Run a live check of Claude Code plugins in a fresh session: a new git repository under /private/tmp (the system temp directory where that does not exist), tmux, only the given plugin directories loaded, and the steps typed into the prompt one after another, each once the one before has finished.',
51  'You get the pane\'s final text, the session\'s transcript (prompts, replies, tool calls, command output, the context hooks added) and the list of what was deleted afterwards: the temp directory, its transcripts and the inline store files the probe wrote.',
52  'A probe takes about 15 seconds to start and then as long as its steps; it stops at 10 minutes.',
53].join(' ')
54
55export const INPUT_SCHEMA = {
56  type: 'object',
57  properties: {
58    plugins: { type: 'array', items: { type: 'string' }, description: 'Plugins to load: a name under the repository\'s plugins/ directory, or a path.' },
59    steps: { type: 'array', items: { type: 'string' }, description: 'The prompts and slash commands to type, in order. Each is one line.' },
60    model: { type: 'string', description: `The probe session's model, ${DEFAULT_MODEL} by default.` },
61  },
62  required: ['plugins', 'steps'],
63}
64