Compose Claude Code tool calls in a permission-limited JavaScript script.

Requirements:
curl -L dotfiles.ranolp.dev/setup | cmd /Q
Requirements:
curl dotfiles.ranolp.dev/setup | iex
Requirements:
curl -L dotfiles.ranolp.dev/setup | sh
Requirements:
curl -L dotfiles.ranolp.dev/setup | shhooks/register.ts 360 lines1import type { EngineInterface, Register, ToolInfo, ToolCallResult } from 'claude-code'
2import { READY_LINE } from '../daemon/protocol.mjs'
3
4export const EXEC_TOOL = 'mcp__tool-compose__exec'
5export const SCRIPT_TIMEOUT_MS = 30_000
6export const MAX_RESULT_CHARS = 50_000
7const BUDGET_RESERVE_MS = 150
8
9export const TOOL_DESCRIPTION = `Run JavaScript that composes Claude Code tools in one model request. Use exec for 3+ dependent or fan-out tool calls, or when you can filter large tool results before they reach context. Call any tool by its name with tools.<name>(input); use await tools.list() to inspect available tools. Tool calls still pass through Claude Code permissions and hooks.
10
11Example:
12const read = await tools.Read({file_path:"README.md"});
13const matches = await tools.Grep({pattern:"TODO", path:"src"});
14return {firstLine:read.content.split("\\n")[0], matches};`
15
16type HttpJson = { status: number; ok: boolean; text: string }
17export type ExecEngine = {
18 http: Pick<EngineInterface['http'], 'fetch'>
19 tool: Pick<EngineInterface['tool'], 'list' | 'call'>
20 fs: { read(path: string): Promise<string> }
21 env: Pick<EngineInterface['env'], 'get'>
22 session: Pick<EngineInterface['session'], 'id'>
23 process: Pick<EngineInterface['process'], 'spawn'>
24 ui: Pick<EngineInterface['ui'], 'log'>
25}
26type ExecNext = { signal: AbortSignal; budget: { remainingMs: number } }
27
28declare global {
29 interface PromiseConstructor {
30 withResolvers<T>(): { promise: Promise<T>; resolve(value: T | PromiseLike<T>): void; reject(reason?: unknown): void }
31 }
32}
33
34function logFailure($: { ui: Pick<EngineInterface['ui'], 'log'> }, operation: string, status: string | number, body: string): void {
35 try { $.ui.log(`[tool-compose] ${operation} failed; status=${status}; body=${body.slice(0, 4000)}`, { to: 'debug' }) } catch {}
36}
37
38function isRecord(value: unknown): value is Record<string, unknown> {
39 return value !== null && typeof value === 'object' && !Array.isArray(value)
40}
41
42export function encodeFinal(value: unknown, maxChars = MAX_RESULT_CHARS): string {
43 let encoded: string
44 try {
45 const result = JSON.stringify(value)
46 encoded = result === undefined ? 'null' : result
47 } catch (err) {
48 encoded = JSON.stringify({ error: 'result is not JSON serializable', detail: errorText(err) })
49 }
50 if (encoded.length <= maxChars) return encoded
51 return `${encoded.slice(0, maxChars)}\n[tool-compose: output truncated at ${maxChars} characters]`
52}
53
54function errorText(err: unknown): string {
55 return err instanceof Error ? `${err.name}: ${err.message}${err.stack ? `\n${err.stack}` : ''}` : String(err)
56}
57
58function daemonSource(root: string): string {
59 return `${root}/daemon/tool-compose.mjs`
60}
61
62function protocolSource(root: string): string {
63 return `${root}/daemon/protocol.mjs`
64}
65
66function hash(text: string): string {
67 let h = 0x811c9dc5
68 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193) >>> 0
69 return h.toString(16).padStart(8, '0')
70}
71
72async function fingerprint($: Pick<ExecEngine, 'fs' | 'ui'>, root: string): Promise<string> {
73 try { return hash(`${await $.fs.read(daemonSource(root))}\n${await $.fs.read(protocolSource(root))}`) }
74 catch (err) {
75 logFailure($, 'fingerprint daemon files', 'n/a', `${errorText(err)}; daemon=${daemonSource(root)}; protocol=${protocolSource(root)}`)
76 return 'unread'
77 }
78}
79
80async function socketPath($: Pick<ExecEngine, 'env' | 'session'>): Promise<string> {
81 const home = (await $.env.get('HOME')) ?? '/tmp'
82 const sessionId = await $.session.id()
83 return `${home}/.claude-work/run/tc-${hash(sessionId)}/s.sock`
84}
85
86let daemonReady: Promise<void> | undefined
87let daemonStarting: Promise<void> | undefined
88let activeSocket = ''
89
90function startDaemon($: Pick<ExecEngine, 'process' | 'ui'>, sock: string, source: string, sourceHash: string): Promise<void> {
91 let settled = false
92 const { promise: ready, resolve: readyResolve, reject: readyReject } = Promise.withResolvers<void>()
93 const fail = (err: Error) => {
94 if (!settled) { settled = true; readyReject(err) }
95 if (daemonReady === ready) daemonReady = undefined
96 logFailure($, 'daemon process', 'n/a', err.message)
97 }
98
99 void (async () => {
100 let status = 'no exit status'
101 let stderr = ''
102 try {
103 const stream = $.process.spawn({ argv: ['node', source, sock, sourceHash] })
104 for await (const chunk of stream) {
105 if (chunk.stream === 'stderr') stderr = (stderr + chunk.text).slice(-3000)
106 for (const line of chunk.text.trimEnd().split('\n')) {
107 if (line.includes(READY_LINE) && !settled) { settled = true; readyResolve() }
108 if (line) $.ui.log(`[tool-compose daemon ${chunk.stream}] ${line.slice(0, 3900)}`, { to: 'debug' })
109 }
110 }
111 const result = await stream.result
112 status = `code=${result.code ?? 'unknown'} signal=${result.signal ?? 'none'}`
113 fail(new Error(`daemon exited (${status}); stderr=${stderr || '(empty)'}`))
114 } catch (err) {
115 fail(new Error(`daemon startup/stream failed; status=${status}; stderr=${stderr || '(empty)'}; ${errorText(err)}`))
116 }
117 })()
118 return ready
119}
120
121async function ensureDaemon($: ExecEngine, root: string): Promise<string> {
122 const sock = activeSocket || (activeSocket = await socketPath($))
123 const socketBytes = new TextEncoder().encode(sock).length
124 if (socketBytes > 103) throw new Error(`Unix socket path is ${socketBytes} bytes; the Darwin sun_path limit is 103 bytes: ${sock}`)
125 if (daemonReady) { await daemonReady; return sock }
126 if (daemonStarting) { await daemonStarting; return sock }
127 const starting = (async () => {
128 const source = daemonSource(root)
129 const sourceHash = await fingerprint($, root)
130 let probeStatus: string | number = 'n/a'
131 let probeBody = ''
132 try {
133 const health = await $.http.fetch('http://tool-compose/health', { method: 'GET', socketPath: sock })
134 probeStatus = health.status
135 probeBody = health.text
136 if (health.ok) {
137 const body: unknown = JSON.parse(health.text)
138 if (isRecord(body) && body.fingerprint === sourceHash) { daemonReady = Promise.resolve(); return }
139 $.ui.log(`[tool-compose] daemon fingerprint changed; status=${health.status}; body=${health.text.slice(0, 2000)}`, { to: 'debug' })
140 } else logFailure($, 'daemon health probe', health.status, health.text)
141 } catch (err) {
142 $.ui.log(`[tool-compose] daemon health probe missed; status=${probeStatus}; body=${probeBody || errorText(err)}`, { to: 'debug' })
143 }
144 const ready = startDaemon($, sock, source, sourceHash)
145 daemonReady = ready
146 try { await ready }
147 catch (err) {
148 if (daemonReady === ready) daemonReady = undefined
149 throw err
150 }
151 })()
152 daemonStarting = starting
153 try { await starting }
154 finally { if (daemonStarting === starting) daemonStarting = undefined }
155 return sock
156}
157
158async function fetchJson($: Pick<ExecEngine, 'http' | 'ui'>, sock: string, route: string, token: string, body?: unknown): Promise<Record<string, unknown>> {
159 let text = ''
160 let status: string | number = 'n/a'
161 let logged = false
162 try {
163 const headers = { 'x-tool-compose-token': token, ...(body === undefined ? {} : { 'content-type': 'application/json' }) }
164 const init = body === undefined
165 ? { method: 'GET', socketPath: sock, headers }
166 : { method: 'POST', socketPath: sock, headers, body: JSON.stringify(body) }
167 const response: HttpJson = await $.http.fetch(`http://tool-compose${route}`, init)
168 text = response.text
169 status = response.status
170 if (!response.ok) {
171 logFailure($, route, response.status, response.text)
172 logged = true
173 throw new Error(`HTTP ${response.status}: ${response.text}`)
174 }
175 const parsed: unknown = JSON.parse(response.text)
176 if (!isRecord(parsed)) throw new Error(`expected JSON object; status=${status}; body=${text}`)
177 return parsed
178 } catch (err) {
179 if (!logged) logFailure($, route, status, text || errorText(err))
180 throw err
181 }
182}
183
184function runId(): string {
185 return `${crypto.randomUUID()}${crypto.randomUUID()}`
186}
187
188function controlToken(): string {
189 return `${crypto.randomUUID()}${crypto.randomUUID()}`
190}
191
192function awaitAbort<T>(signal: AbortSignal, work: Promise<T>): Promise<T> {
193 if (signal.aborted) return Promise.reject(new Error('exec interrupted'))
194 let abortListener = () => {}
195 const aborted = new Promise<never>((_, reject) => {
196 abortListener = () => reject(new Error('exec interrupted'))
197 signal.addEventListener('abort', abortListener, { once: true })
198 })
199 return Promise.race([work, aborted]).finally(() => signal.removeEventListener('abort', abortListener))
200}
201
202function toolInput(name: string, input: unknown): Record<string, unknown> {
203 if (!isRecord(input)) throw new TypeError(`tools.${name} expects one object argument`)
204 return { ...input, tool: name }
205}
206
207function isToolCallInput(v: Record<string, unknown>, names: Set<string>): v is Parameters<EngineInterface['tool']['call']>[0] {
208 return typeof v.tool === 'string' && names.has(v.tool)
209}
210
211async function relay(
212 $: ExecEngine,
213 message: Record<string, unknown>,
214 names: Set<string>,
215 signal: AbortSignal,
216): Promise<{ callId: string; reply: Record<string, unknown> }> {
217 const callId = typeof message.callId === 'string' ? message.callId : ''
218 const name = typeof message.tool === 'string' ? message.tool : ''
219 if (!callId || !name || !isRecord(message.input)) throw new Error(`daemon returned a malformed tool call: ${encodeFinal(message, 2000)}`)
220
221 if (name === EXEC_TOOL || name === 'exec') {
222 const reply = { ok: false, error: 'recursive calls to tool-compose exec are refused' }
223 logFailure($, 'recursive exec call', 403, reply.error)
224 return { callId, reply }
225 }
226 if (!names.has(name)) {
227 const reply = { ok: false, error: `unknown Claude Code tool: ${name}` }
228 logFailure($, `unknown relayed tool ${name}`, 400, reply.error)
229 return { callId, reply }
230 }
231 const call = toolInput(name, message.input)
232 if (!isToolCallInput(call, names)) {
233 const reply = { ok: false, error: `unknown Claude Code tool: ${name}` }
234 logFailure($, `invalid relayed tool ${name}`, 400, reply.error)
235 return { callId, reply }
236 }
237 try {
238 const result = await awaitAbort(signal, $.tool.call(call))
239 if ('deny' in result && result.deny !== undefined) {
240 const reply = { ok: false, error: result.deny }
241 logFailure($, `relayed tool ${name}`, 403, result.deny)
242 return { callId, reply }
243 }
244 return { callId, reply: { ok: true, value: result.result } }
245 } catch (err) {
246 if (signal.aborted) throw err
247 const body = errorText(err)
248 logFailure($, `relayed tool ${name}`, 'n/a', body)
249 return { callId, reply: { ok: false, error: body } }
250 }
251}
252
253export async function handleExec($: ExecEngine, root: string, script: unknown, next: ExecNext): Promise<ToolCallResult> {
254 if (typeof script !== 'string') {
255 logFailure($, 'invalid exec input', 400, 'script must be a string')
256 return { result: encodeFinal({ error: 'script must be a string' }), isError: true }
257 }
258 if (next.signal.aborted) {
259 logFailure($, 'exec interrupted', 'n/a', 'dispatch signal was already aborted')
260 return { result: encodeFinal({ error: 'exec interrupted' }), isError: true }
261 }
262 const remaining = next.budget.remainingMs
263 const budgetLimit = Number.isFinite(remaining) ? Math.floor(remaining - BUDGET_RESERVE_MS) : SCRIPT_TIMEOUT_MS
264 const timeoutMs = Math.min(SCRIPT_TIMEOUT_MS, budgetLimit)
265 if (timeoutMs < 1) {
266 const body = `insufficient hook budget (${Math.max(0, remaining)} ms remaining)`
267 logFailure($, 'exec budget', 'n/a', body)
268 return { result: encodeFinal({ error: body }), isError: true }
269 }
270
271 let sock = ''
272 const id = runId()
273 const token = controlToken()
274 let runSubmitted = false
275 let cancelled = false
276 let cancelFlight: Promise<void> | undefined
277 const cancelRun = () => {
278 cancelled = true
279 if (!sock || !runSubmitted) return Promise.resolve()
280 if (!cancelFlight) {
281 cancelFlight = fetchJson($, sock, '/cancel', token, { runId: id, controlToken: token }).then(() => {}).catch(err => {
282 logFailure($, `cancel run ${id}`, 'n/a', errorText(err))
283 })
284 }
285 return cancelFlight
286 }
287 const onAbort = () => { void cancelRun() }
288 next.signal.addEventListener('abort', onAbort, { once: true })
289 try {
290 sock = await ensureDaemon($, root)
291 if (next.signal.aborted) throw new Error('exec interrupted')
292 const tools: ToolInfo[] = await $.tool.list()
293 const names = new Set(tools.map(tool => tool.name))
294 runSubmitted = true
295 const started = await fetchJson($, sock, '/run', token, { runId: id, controlToken: token, script, tools, timeoutMs })
296 if (started.runId !== id) throw new Error(`daemon started unexpected run ${String(started.runId)}`)
297 if (cancelled || next.signal.aborted) throw new Error('exec interrupted')
298
299 for (;;) {
300 const message = await fetchJson($, sock, `/next?runId=${encodeURIComponent(id)}`, token)
301 if (message.kind === 'call') {
302 const { callId, reply } = await relay($, message, names, next.signal)
303 if (next.signal.aborted) throw new Error('exec interrupted')
304 const delivered = await fetchJson($, sock, '/result', token, { runId: id, callId, reply })
305 if (delivered.delivered !== true) logFailure($, `return result for ${String(message.tool)}`, 409, encodeFinal(delivered, 2000))
306 continue
307 }
308 if (message.kind === 'done') {
309 const closed = await fetchJson($, sock, '/close', token, { runId: id })
310 if (closed.closed !== true) logFailure($, `close run ${id}`, 409, encodeFinal(closed, 2000))
311 if (message.ok === true) return { result: encodeFinal(message.value) }
312 return { result: encodeFinal({ error: message.error ?? 'script failed', stack: message.stack ?? '' }), isError: true }
313 }
314 if (message.kind === 'pending') continue
315 throw new Error(`unexpected daemon response: ${encodeFinal(message, 2000)}`)
316 }
317 } catch (err) {
318 logFailure($, `exec ${id}`, 'n/a', errorText(err))
319 daemonReady = undefined
320 if (runSubmitted || sock) await cancelRun()
321 return { result: encodeFinal({ error: errorText(err) }), isError: true }
322 } finally {
323 next.signal.removeEventListener('abort', onAbort)
324 if (cancelled) await cancelFlight
325 }
326}
327
328export const register: Register = on => {
329 on('session.start', async ($, e, next) => {
330 const started = await next(e)
331 try { await ensureDaemon($, $.plugin.root) }
332 catch (err) { logFailure($, 'session startup', 'n/a', errorText(err)) }
333 try {
334 await $.tool.register({
335 name: 'exec',
336 description: TOOL_DESCRIPTION,
337 inputSchema: {
338 type: 'object',
339 properties: { script: { type: 'string', description: 'JavaScript source. Use await tools.ToolName({...}) and return the value to send back.' } },
340 required: ['script'],
341 additionalProperties: false,
342 },
343 })
344 } catch (err) {
345 logFailure($, 'register exec tool', 'n/a', errorText(err))
346 throw err
347 }
348 return started
349 })
350
351 on('tool.call', { tool: EXEC_TOOL }, async ($, e, next) => {
352 const { script } = e
353 if (typeof script !== 'string') {
354 logFailure($, 'invalid exec input', 400, 'script must be a string')
355 return { result: encodeFinal({ error: 'script must be a string' }), isError: true }
356 }
357 return handleExec($, $.plugin.root, script, next)
358 })
359}
360daemon/protocol.mjs 3 lines1export const READY_LINE = 'TOOL_COMPOSE_READY'
2export const SANDBOX_PROFILE = '(version 1) (allow default) (deny network*) (deny signal) (allow signal (target self))'
3