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…

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.
&& /<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.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./disk-janitor) waits until that pane closes./cost, /model) holds its command until it closes.1/2: /tiny 2/2: /context all 2 command(s) ran stopped after /tiny: its turn ended with aborted; not run: /context
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).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.
/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
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.
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.
fail call.mcp__slash-chain__fail, at the session's startfail 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&& /<name> in a command's arguments runs that command toofail tool stands in the tool list of every session, also with no chain, because a tool cannot be taken back once declared.origin.kind: 'plugin'), so a command that answers only the person refuses there. /disk-janitor delete <path> is one.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.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.&& is read. ||, ; and | stay in the arguments.claude -p) was not measured.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
hooks/register.ts 234 lines1import 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}
234hooks/chain.ts 109 lines1/** 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