SLOPSHOPPER

slash-chain

Runs slash commands joined with && one after another, waits for each command's turn, pane or dialog to end, and stops the chain at a command that failed or a…

newguardcommandprompttooltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slash-chain
› 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 › /slash-chain ⎿ slash-chain: on · no chain runs ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

slash-chain

You type /init && /initialize and expect the second to run too; the engine runs the first command alone and drops the rest without a word. This mod runs slash commands joined with && one after another, as a shell does: /tiny && /context runs /context once /tiny ended well.

What it does

  1. The engine hands everything after the first command's name to that command as its arguments. The mod splits them at each && /<name>: the first command runs with its own arguments alone, and every /<name> after a && becomes a step with its own arguments. A && that no /<name> follows stays in the arguments, so /commit build && test keeps its text.
  2. Each step waits for what it started before the next one runs:
  3. A command that hands the model a prompt (a skill or a prompt command) waits for its turn to end. A turn that ends with answer runs the next step. A turn that ends with error, refusal or aborted (Esc) stops the chain. A question the model asks with AskUserQuestion stays inside that turn, so the chain waits for the answer too.
  4. A command that opens a focused pane (/disk-janitor) waits until that pane closes.
  5. A built-in dialog (/cost, /model) holds its command until it closes.
  6. Any other command is done when it returns.
  7. A command that throws stops the chain.
  8. Each step gets one transcript line, and the chain gets one last line:

1/2: /tiny 2/2: /context all 2 command(s) ran stopped after /tiny: its turn ended with aborted; not run: /context

  1. A prompt or a new chain you type while a chain waits cancels it: cancelled; not run: /context. The new chain starts from there. A single command you type (/cost) runs beside the waiting chain and does not cancel it, because the mod hooks only the commands whose arguments hold && /<name>. The engine still writes this mod's name in front of every plugin command's output (task-poke+slash-chain: ...): it picks the names it writes by a hook's command matcher alone, and a chain can start with any command, so this hook cannot name one (measured on 2.1.281 with two probe plugins: a hook with only an args matcher that never ran was named, a hook with a command matcher was not).

When a step fails in words

The engine gives a turn no exit code: a model that could not do a step, says so and ends its turn normally ends it with answer, and the next step would run. So a chained skill or prompt step that has steps after it gets one line after its text:

[slash-chain] This is step 1/2 of a chain; after it: /exit. If you could not do what this step asks, call the mcp__slash-chain__fail tool with the reason before you end your turn, and the steps after it do not run.

The mod declares the tool mcp__slash-chain__fail (reason) at the session's start and keeps it in the model's tool list, not behind ToolSearch, so the model can call it at once. A call stops the chain:

stopped after /fail: the model reported it failed: Writing /nonexistent-dir/CLAUDE.md failed: EROFS read-only file system, could not create /nonexistent-dir.; not run: /exit

Measured on 2.1.281: /fail && /exit, where /fail asked for a write that failed, called the tool, stopped before /exit, and the session stayed open; /ok && /context did not call it and ran both. A call while no chained step waits on the turn, from a subagent, or without a reason is refused. The last step of a chain and a command outside a chain get no line.

Command

/slash-chain the setting and the chain that runs, with what it waits for /slash-chain stop cancels the chain that runs /slash-chain on | off on by default; off leaves the command as the engine hands it

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install slash-chain@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. Restart Claude Code.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, tool.describe{tool=mcp__slash-chain__fail}, tool.call{tool=mcp__slash-chain__fail}, command.run{command=slash-chain}, command.run{args=/"(?:^|\\s)&&\\s*\\/[A-Za-z0-9_:.-]+(?=\\s|$)"/}, skill.prompt, ui.open, ui.close, turn.complete, prompt.submit ❯ ./register.ts calls: $.clock.after (via advance), $.command.register, $.command.run (via runStep), $.store.get (via readSettings), $.store.set (via setEnabled), $.tool.register, $.ui.log (via advance, cancel, runFirst, stop)

Reach L2, it drives Claude: it runs the slash commands you chained.

  1. Reads: the arguments of every slash command, the name of each skill prompt, each pane's id and focus, and each main-loop turn's end reason. It reads no file, no prompt text and no answer; it reads the reason of a fail call.
  2. Runs: each command after the first of a chain, once, with the arguments you typed for it; declares one tool, mcp__slash-chain__fail, at the session's start
  3. Sends: to the model, the fail tool in its tool list and one line after the text of a chained skill or prompt step that has steps after it; nothing to the network
  4. Persists: in $.store, the on/off setting
  5. Hostile input: the chain comes from the text you typed; a step runs only a command the engine knows, with its own arguments, and a pasted text that holds && /<name> in a command's arguments runs that command too

Limits

  • The fail tool stands in the tool list of every session, also with no chain, because a tool cannot be taken back once declared.
  • The steps after the first run as a plugin (origin.kind: 'plugin'), so a command that answers only the person refuses there. /disk-janitor delete <path> is one.
  • A local command succeeds when it does not throw. The engine gives no other error signal, so a command that prints an error and returns counts as done.
  • A skill or prompt command succeeds when its turn ends with answer and the model did not call fail. A model that says it failed without calling the tool still counts as done: measured on 2.1.281 before the tool, /fail && /exit, where /fail answered I could not write CLAUDE.md., ran /exit, and the session closed. A turn you stop with Esc does stop the chain: /slow && /exit stopped with stopped after /slow: its turn ended with aborted; not run: /exit, and the session stayed open.
  • /exit runs as a later step: the engine takes it from a plugin, and the session closes.
  • An unknown first command never reaches the mod: the engine answers Unknown skill, and nothing after it runs. An unknown later command stops the chain with the engine's error: stopped after /x: it failed: $.command.run: no command named /x in this session.
  • Only && is read. ||, ; and | stay in the arguments.
  • A focused pane is recognized when it opens during its step. A pane a command opens later, from a timer, is not waited for.
  • The chain was checked in an interactive session alone. A headless session (claude -p) was not measured.

Development

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

Source 2 files
hooks/register.ts 234 lines
1import type { CommandRunInput, CommandRunResult, EngineInterface, Register, ToolCallInput, ToolCallResult } from 'claude-code'
2import { cancelledText, doneText, FAIL_DESCRIPTION, FAIL_SCHEMA, FAIL_TOOL, failedWhy, failNote, parseChain, reasonOf, statusText, stepText, stoppedText, thrownWhy, turnWhy, type Step, type Wait } from './chain.ts'
3
4const ENABLED_KEY = 'enabled'
5const COMMAND = 'slash-chain'
6const USAGE = 'expects nothing (the status), stop, on or off'
7
8/** The person's own input: the prompt's Enter or the bridge. */
9const PERSON: ReadonlySet<string> = new Set(['composer', 'bridge'])
10
11/**
12 * The running chain: the step that runs, the steps after it, and what the step waits for. A step that
13 * handed the model a prompt (`skill.prompt` came while it ran) waits for its turn; one that opened a
14 * focused pane waits for that pane to close. `turnEnded` and `paneClosed` hold an end that came before
15 * the step's run resolved.
16 */
17type Running = {
18  step: Step
19  left: Step[]
20  index: number
21  total: number
22  wait: Wait
23  sawPrompt: boolean
24  turnEnded?: string
25  pane?: string
26  paneClosed: boolean
27}
28
29type State = { enabled: boolean; running?: Running }
30
31type Next = (e: CommandRunInput) => Promise<CommandRunResult>
32
33function startStep(state: State, step: Step, left: Step[], index: number, total: number): Running {
34  const running: Running = { step, left, index, total, wait: 'run', sawPrompt: false, paneClosed: false }
35  state.running = running
36  return running
37}
38
39function stop($: EngineInterface, state: State, why: string): void {
40  const r = state.running
41  if (r === undefined) return
42  state.running = undefined
43  $.ui.log(stoppedText(r.step, why, r.left))
44}
45
46function cancel($: EngineInterface, state: State): string {
47  const r = state.running
48  if (r === undefined) return 'no chain runs'
49  state.running = undefined
50  const text = cancelledText(r.left)
51  $.ui.log(text)
52  return text
53}
54
55/** Runs the next step from a timer, because a command does not run from inside another's hook; the last one ends the chain. */
56function advance($: EngineInterface, state: State, r: Running): void {
57  const [step, ...left] = r.left
58  if (step === undefined) {
59    state.running = undefined
60    $.ui.log(doneText(r.total))
61    return
62  }
63  const next = startStep(state, step, left, r.index + 1, r.total)
64  $.ui.log(stepText(next.index, next.total, step))
65  $.clock.after(0, () => void runStep($, state, next))
66}
67
68function endTurn($: EngineInterface, state: State, r: Running, reason: string): void {
69  if (reason === 'answer') advance($, state, r)
70  else stop($, state, turnWhy(reason))
71}
72
73/** After a step's run resolved: wait for the turn it started or the pane it opened, else go on. */
74function afterRun($: EngineInterface, state: State, r: Running): void {
75  if (state.running !== r) return
76  if (r.sawPrompt) {
77    if (r.turnEnded === undefined) r.wait = 'turn'
78    else endTurn($, state, r, r.turnEnded)
79    return
80  }
81  if (r.pane !== undefined && !r.paneClosed) {
82    r.wait = 'pane'
83    return
84  }
85  advance($, state, r)
86}
87
88/** A step after the first, run as a plugin: the engine answers it as it would the typed command. */
89async function runStep($: EngineInterface, state: State, r: Running): Promise<void> {
90  if (state.running !== r) return
91  try {
92    await $.command.run({ command: r.step.command, args: r.step.args })
93  } catch (err) {
94    if (state.running === r) stop($, state, thrownWhy(err))
95    return
96  }
97  afterRun($, state, r)
98}
99
100/**
101 * Reads the on/off setting from the store, which every window shares, so a change made in another
102 * window applies here at the next hook that acts on it.
103 */
104async function readSettings($: EngineInterface, state: State): Promise<void> {
105  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
106}
107
108/** The typed first command of a chain runs with its own arguments alone; the steps after it wait their turn. */
109async function runFirst($: EngineInterface, state: State, e: CommandRunInput, next: Next): Promise<CommandRunResult> {
110  const chain = parseChain(e.args)
111  if (chain.rest.length === 0) return next(e)
112  await readSettings($, state)
113  if (!state.enabled) return next(e)
114  const step = { command: e.command, args: chain.head }
115  const r = startStep(state, step, chain.rest, 1, chain.rest.length + 1)
116  $.ui.log(stepText(1, r.total, step))
117  let result: CommandRunResult
118  try {
119    result = await next({ ...e, args: chain.head })
120  } catch (err) {
121    if (state.running === r) stop($, state, thrownWhy(err))
122    throw err
123  }
124  afterRun($, state, r)
125  return result
126}
127
128async function setEnabled($: EngineInterface, state: State, on: boolean): Promise<string> {
129  state.enabled = on
130  await $.store.set(ENABLED_KEY, on)
131  if (!on && state.running !== undefined) cancel($, state)
132  return on ? 'on: /a && /b runs /b once /a ended well' : 'off: the engine runs the first command alone, as it does without the mod'
133}
134
135async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
136  const word = args.trim()
137  if (word === 'on' || word === 'off') return setEnabled($, state, word === 'on')
138  if (word === 'stop') return cancel($, state)
139  if (word !== '') return USAGE
140  await readSettings($, state)
141  const r = state.running
142  return statusText(state.enabled, r === undefined ? undefined : { step: r.step, index: r.index, total: r.total, wait: r.wait })
143}
144
145/**
146 * The model's report that the step it was handed failed: the chain stops before the next step. Only a step
147 * that handed the main loop a prompt can be reported, because only that step waits on the model's turn.
148 */
149function failStep($: EngineInterface, state: State, e: ToolCallInput): ToolCallResult {
150  const r = state.running
151  if (r === undefined || !r.sawPrompt || e.agentId !== undefined) return { deny: 'no slash-chain step waits on this turn, so there is nothing to stop' }
152  const reason = reasonOf(e as Record<string, unknown>)
153  if (reason === undefined) return { deny: 'reason is required: say in one sentence why the step failed' }
154  stop($, state, failedWhy(reason))
155  return { result: 'The chain stopped; the steps after this one do not run.' }
156}
157
158/** Whether a prompt the person sent belongs to the running step (a prompt command's own text) or is this mod's command. */
159function isOwnPrompt(text: string, r: Running): boolean {
160  return text.startsWith(`/${r.step.command}`) || text.startsWith(`/${COMMAND}`)
161}
162
163export const register: Register = on => {
164  const state: State = { enabled: true }
165
166  on('session.start', async ($, e, next) => {
167    const r = await next(e)
168    await readSettings($, state)
169    await $.command.register({ name: COMMAND, description: 'Runs /a && /b one after another: status, stop, on, off (slash-chain)', argumentHint: '[stop | on | off]', immediate: true })
170    // Declared once at the start, so the tool list the prompt cache holds does not change mid-session.
171    await $.tool.register({ name: FAIL_TOOL, description: FAIL_DESCRIPTION, inputSchema: FAIL_SCHEMA })
172    return r
173  })
174
175  // A plugin's tool waits behind ToolSearch by default; this one is listed, so the model can call it at once.
176  on('tool.describe', { tool: 'mcp__slash-chain__fail' }, async (_, e, next) => ({ ...(await next(e)), isDeferred: false }))
177
178  on('tool.call', { tool: 'mcp__slash-chain__fail' }, async ($, e) => failStep($, state, e))
179
180  on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runCommand($, state, e.args) }))
181
182  // Only a command whose arguments hold `&& /<name>` (SEPARATOR in chain.ts) runs this hook, so a single
183  // command typed while a chain waits does not cancel it. The engine still names this plugin in front of
184  // every command's output: it picks those names by a hook's `command` matcher alone, and a chain can start
185  // with any command (measured on 2.1.281). A literal, so `claude plugin validate` prints the pattern.
186  on('command.run', { args: /(?:^|\s)&&\s*\/[A-Za-z0-9_:.-]+(?=\s|$)/ }, async ($, e, next) => {
187    // A new chain the person types while one waits ends the waiting one, then starts its own.
188    if (state.running !== undefined && PERSON.has(e.origin.kind)) cancel($, state)
189    return runFirst($, state, e, next)
190  })
191
192  // A step that hands the model a prompt waits for its turn; the text tells the model how to report a failure.
193  on('skill.prompt', async (_, e, next) => {
194    const result = await next(e)
195    const r = state.running
196    if (r?.wait !== 'run') return result
197    r.sawPrompt = true
198    return r.left.length === 0 ? result : { ...result, text: `${result.text.trimEnd()}\n\n${failNote(r.index, r.total, r.left)}` }
199  })
200
201  on('ui.open', async (_, e, next) => {
202    const result = await next(e)
203    const r = state.running
204    if (r?.wait === 'run' && e.focus === true && result.value?.isPlaced === true) r.pane = e.id
205    return result
206  })
207
208  on('ui.close', async ($, e, next) => {
209    const r = state.running
210    if (r !== undefined && r.pane === e.id) {
211      if (r.wait === 'pane') advance($, state, r)
212      else r.paneClosed = true
213    }
214    return next(e)
215  })
216
217  on('turn.complete', async ($, e, next) => {
218    const result = await next(e)
219    const r = state.running
220    // A subagent's turn ends inside the step's own; only the main loop's end is the step's.
221    if (r === undefined || !r.sawPrompt || e.agentId !== undefined) return result
222    if (r.wait === 'turn') endTurn($, state, r, e.reason)
223    else if (r.wait === 'run') r.turnEnded = e.reason
224    return result
225  })
226
227  on('prompt.submit', async ($, e, next) => {
228    const r = state.running
229    const kind = (e.origin as { kind?: string } | undefined)?.kind
230    if (r !== undefined && kind !== undefined && PERSON.has(kind) && !isOwnPrompt(e.text, r)) cancel($, state)
231    return next(e)
232  })
233}
234
hooks/chain.ts 109 lines
1/** A chain of slash commands joined with `&&`, and the texts this mod writes. */
2
3/** One command of the chain: its name without the slash, and its arguments as typed. */
4export type Step = { command: string; args: string }
5
6/** The command typed first keeps its own arguments (`head`); every `&& /<name>` after them starts a step. */
7export type Chain = { head: string; rest: Step[] }
8
9/**
10 * `&&`, then a slash and a command name, as a word of its own. A `&&` that no `/<name>` follows is left in
11 * the arguments, so `/commit a && b` keeps its text.
12 */
13const SEPARATOR = /(?:^|\s)&&\s*\/([A-Za-z0-9_:.-]+)(?=\s|$)/g
14
15/** The arguments the engine handed the first command, split at each `&& /<name>`. */
16export function parseChain(args: string): Chain {
17  const marks = [...args.matchAll(SEPARATOR)]
18  const first = marks[0]
19  if (first === undefined) return { head: args, rest: [] }
20  const rest = marks.map((m, i) => ({
21    command: m[1] ?? '',
22    args: args.slice(m.index + m[0].length, marks[i + 1]?.index ?? args.length).trim(),
23  }))
24  return { head: args.slice(0, first.index).trim(), rest }
25}
26
27/** How a step reads in the transcript: `/name args`. */
28export function stepName(step: Step): string {
29  return step.args === '' ? `/${step.command}` : `/${step.command} ${step.args}`
30}
31
32/** `; not run: /b, /c`, or nothing after the last step. */
33function notRun(steps: readonly Step[]): string {
34  return steps.length === 0 ? '' : `; not run: ${steps.map(s => `/${s.command}`).join(', ')}`
35}
36
37/** The line before a step runs: `2/3: /test2`. The engine adds the mod name. */
38export function stepText(index: number, total: number, step: Step): string {
39  return `${index}/${total}: ${stepName(step)}`
40}
41
42export function doneText(total: number): string {
43  return `all ${total} command(s) ran`
44}
45
46/** Why a step stopped the chain: a command that threw, or a turn that ended without an answer. */
47export function thrownWhy(err: unknown): string {
48  // The engine names the calling plugin in front of its error, and it names the plugin in front of the line again.
49  const message = (err instanceof Error ? err.message : String(err)).replace(/^slash-chain: /, '')
50  return `it failed: ${message}`
51}
52
53export function turnWhy(reason: string): string {
54  return `its turn ended with ${reason}`
55}
56
57export function failedWhy(reason: string): string {
58  return `the model reported it failed: ${reason}`
59}
60
61/**
62 * The tool the model calls when a step it was handed failed. The engine gives a turn no exit code, so a model
63 * that says it failed and ends its turn normally would run the next step (measured: `/fail && /exit` exited).
64 */
65export const FAIL_TOOL = 'fail'
66export const FAIL_TOOL_ID = `mcp__slash-chain__${FAIL_TOOL}` as const
67
68export const FAIL_DESCRIPTION = 'Stops the running slash-chain (/a && /b) because the step you were handed failed, so the commands after it do not run. Call it only while a slash-chain step waits on your turn, and only when you could not do what that step asked; a step you did does not need it.'
69
70export const FAIL_SCHEMA = {
71  type: 'object',
72  properties: {
73    reason: { type: 'string', description: 'Why the step failed, in one sentence.' },
74  },
75  required: ['reason'],
76}
77
78/** The line the model reads after a chained step's text: how to stop the steps after it. */
79export function failNote(index: number, total: number, left: readonly Step[]): string {
80  const after = left.length === 0 ? 'none' : left.map(s => `/${s.command}`).join(', ')
81  return `[slash-chain] This is step ${index}/${total} of a chain; after it: ${after}. If you could not do what this step asks, call the ${FAIL_TOOL_ID} tool with the reason before you end your turn, and the steps after it do not run.`
82}
83
84/** The reason a fail call carries, or undefined when it holds none. */
85export function reasonOf(input: Record<string, unknown>): string | undefined {
86  const reason = typeof input.reason === 'string' ? input.reason.trim() : ''
87  return reason === '' ? undefined : reason
88}
89
90export function stoppedText(step: Step, why: string, left: readonly Step[]): string {
91  return `stopped after /${step.command}: ${why}${notRun(left)}`
92}
93
94export function cancelledText(left: readonly Step[]): string {
95  return `cancelled${notRun(left)}`
96}
97
98/** What the chain waits for after its running step. */
99export type Wait = 'run' | 'turn' | 'pane'
100
101const WAITING: Record<Wait, string> = { run: 'running', turn: 'waiting for its turn', pane: 'waiting for its pane to close' }
102
103/** The `/slash-chain` answer. */
104export function statusText(enabled: boolean, running: { step: Step; index: number; total: number; wait: Wait } | undefined): string {
105  const head = enabled ? 'on' : 'off'
106  if (running === undefined) return `${head} · no chain runs`
107  return `${head} · ${running.index}/${running.total} ${stepName(running.step)}, ${WAITING[running.wait]}`
108}
109