Main agent dispatches tasks to persistent Claude Code worker sessions via CLI; workers report back as compact, lossless-on-essentials relay packets.

Ein Claude-Code-Plugin, mit dem ein Main-Agent mehrere eigenständige Claude-Code-Sessions als Worker steuert, z. B. einen Ingester, einen Architekten und einen Researcher, die parallel am selben Wiki oder Projekt arbeiten.
Du redest nur mit dem Main-Agent. Er verteilt Aufgaben an die Worker. Die Worker melden ihre Ergebnisse als kompaktes Relay-Paket zurück: kurz, aber ohne dass Entscheidungen, Dateiänderungen, Blocker oder exakte Angaben (Zahlen, Pfade, URLs, Versionen) verloren gehen. Die vollständigen Antworten liegen auf der Platte und werden nur bei Bedarf nachgeladen. So bleibt der Kontext des Main-Agents klein.
Du
│
Main-Agent ──── worker_send ────► ingester (eigene Session, eigener Kontext)
▲ ──── worker_send ────► architect
│ ──── worker_send ────► researcher
│
└──── Relay-Pakete ◄──────── alle Worker
(Volltext in .relay/runs/…, abrufbar mit worker_expand)
claude -p. Der Main-Agent arbeitet währenddessen weiter.| Werkzeug | Wozu |
|---|---|
worker_send | Gibt einer Rolle eine Aufgabe. Startet sofort oder reiht sie in die Warteschlange ein. |
worker_status | Zeigt, welche Rolle arbeitet, wartet oder fertig ist, und was zuletzt rauskam. |
worker_expand | Holt Details zu einem Paket: einen Abschnitt, das ganze Paket oder den Volltext. |
Der mitgelieferte Skill relay-orchestrator bringt dem Agent bei, wann und wie er diese Werkzeuge einsetzt. Du musst ihm nur sagen, was du erreichen willst, z. B.: „Lass den Ingester diese 10 Links verarbeiten, und der Architekt soll danach prüfen, ob wir neue Tags brauchen.“
Einstellbar in der Mod Box unter ⚙ oder mit /plugin configure relay@<marketplace>.
| Einstellung | Standard | Was sie bewirkt |
|---|---|---|
Claude executable (claudeBin) | claude | Das Programm, mit dem Worker gestartet werden. Nur ändern, wenn claude nicht im PATH liegt, z. B. ein vollständiger Pfad zu claude.exe. |
Worker permission mode (permissionMode) | acceptEdits | Was Worker ohne Rückfrage dürfen. Worker können dich nicht fragen. acceptEdits: Dateien bearbeiten ja, riskante Befehle werden abgelehnt. plan: nur planen, nichts ändern. default: alles, was eine Freigabe bräuchte, wird abgelehnt. bypassPermissions: alles erlaubt, nur in einer Sandbox nutzen. |
Worker model (workerModel) | claude-sonnet-5-5 | Das Modell, mit dem Worker standardmäßig laufen. Leer heißt: das Standardmodell der CLI. Pro Rolle überschreibbar, z. B. Haiku für einen einfachen Ingester. |
Worker effort (workerEffort) | medium | Wie gründlich Worker nachdenken. Höher heißt besser, aber langsamer und teurer. default übernimmt die Einstellung der CLI. Pro Rolle überschreibbar. |
| Einstellung | Standard | Was sie bewirkt |
|---|---|---|
Budget per run (USD) (maxBudgetUsd) | 2 | Höchstbetrag pro Lauf. Ist er erreicht, bricht der Worker ab und meldet partial. 0 = keine Grenze. |
Parallel workers (maxParallel) | 3 | Wie viele Worker gleichzeitig laufen. Weitere Aufgaben warten und starten von selbst, sobald ein Platz frei wird. |
Timeout per run (minutes) (runTimeoutMin) | 30 | Ein Lauf, der länger dauert, wird beendet und als partial oder failed gemeldet. 0 = kein Timeout. |
| Einstellung | Standard | Was sie bewirkt |
|---|---|---|
Allowed tools (allowedTools) | leer | Werkzeuge, die Worker ohne Rückfrage nutzen dürfen, durch Komma getrennt, z. B. Read, Grep, WebFetch, Bash(git status:*). Pro Rolle überschreibbar. |
Disallowed tools (disallowedTools) | Bash(git push:*) | Werkzeuge, die Worker nie nutzen dürfen, z. B. Bash(git push:*), Bash(rm:*). Pro Rolle überschreibbar. |
Enforce ownership (enforceOwns) | an | Ein Worker darf nur in seinen eigenen Pfaden (owns) Dateien anlegen oder ändern. Andere Schreibversuche werden blockiert, und der Worker meldet stattdessen, was geändert werden müsste. Gilt für die Werkzeuge Edit, Write und NotebookEdit. Schreiben über die Shell erfasst der Wächter nicht, dafür disallowedTools nutzen. |
| Einstellung | Standard | Was sie bewirkt |
|---|---|---|
Packet delivery (notifyMode) | prompt | prompt: Ein fertiges Paket weckt den Main-Agent sofort, er reagiert von selbst. note: Das Paket wird still angehängt, der Agent sieht es bei deiner nächsten Nachricht. Spart Runden, wenn viele Worker gleichzeitig fertig werden. toast: Nur eine kurze Einblendung, die Pakete holt sich der Agent über worker_status. |
Packet detail (packetDetail) | full | full: Das Paket enthält alles. lean: nur das, was eine Reaktion braucht (Status, Zusammenfassung, Entscheidungen, Dateien, offene Fragen). Fakten, Risiken und nächste Schritte werden nur gezählt und bei Bedarf nachgeladen. Noch kleinerer Kontext. |
Fallback compression (fallbackCompress) | an | Vergisst ein Worker sein Paket, baut ein günstiges Modell es aus der Antwort nach. Aus: Der Lauf wird als failed gemeldet, der Volltext bleibt lesbar. |
Fallback compressor model (fallbackModel) | claude-haiku-5-5 | Das Modell für diesen Ersatz. Kostet nur, wenn ein Paket fehlt. |
| Einstellung | Standard | Was sie bewirkt |
|---|---|---|
Relay folder (relayDir) | .relay | Projektordner für die Volltexte (runs/) und die Rollen-Datei. |
Roles file (rolesFile) | .relay/roles.json | Datei mit fest definierten Rollen (siehe unten). Sie gehört ins Repository. |
Ignore run files in git (gitignore) | an | Trägt in Git-Projekten .relay/runs/ in die .gitignore ein. Die Rollen-Datei bleibt versioniert. |
Runs kept per role (keepRuns) | 20 | Wie viele Läufe pro Rolle auf der Platte bleiben. Ältere werden überschrieben. 0 = alle behalten. |
Statt dem Agent jedes Mal Rolle, Aufgabenbereich und Rechte zu erklären, legst du sie einmal pro Projekt in .relay/roles.json fest:
{
"roles": {
"ingester": {
"brief": "Verarbeitet Links und Dokumente zu Quellnotizen. Ändert nie Struktur oder Vorlagen.",
"owns": ["wiki/sources/"],
"model": "claude-haiku-5-5",
"effort": "low",
"allowedTools": ["Read", "Write", "WebFetch"],
"disallowedTools": ["Bash"]
},
"architect": {
"brief": "Verantwortet Struktur, Vorlagen, Tags und Verlinkungsregeln des Wikis.",
"owns": ["wiki/_schema/", "wiki/index.md"],
"effort": "high"
},
"researcher": {
"brief": "Beantwortet Fragen aus Wiki und Web und schreibt Recherchenotizen.",
"owns": ["wiki/research/"]
}
}
}
Reihenfolge, wenn etwas mehrfach angegeben ist: Angabe beim Auftrag (worker_send) vor Rollen-Datei vor Einstellungen des Plugins.
Faustregel: Keine zwei Rollen besitzen denselben Pfad. Gemeinsame Dateien wie ein Index gehören genau einer Rolle. Die anderen melden Änderungswünsche über den Main-Agent.
Das Plugin braucht keine Mod Box. Es ist ein eigenständiges Claude-Code-Plugin und nutzt nur die Plugin-API von Claude Code und die claude-CLI.
Über eine Mod Box (als nativ verwaltetes Plugin, weil es einen Skill mitbringt):
node tools/box.mjs native install relay --dir <pfad-zu-diesem-ordner>
Ohne Mod Box, dieser Ordner ist selbst ein Marketplace:
claude plugin marketplace add <pfad-zu-diesem-ordner>
claude plugin install relay@relay
Nur zum Ausprobieren, für eine einzelne Session:
claude --plugin-dir <pfad-zu-diesem-ordner>
Danach Claude neu starten. Prüfen: Den Agent nach worker_status fragen, er sollte „No worker roles yet“ melden.
Wichtig: Pro Installation nur einmal aktivieren. Ist das Plugin über zwei Quellen installiert (z. B. zwei Mod Boxen), registrieren beide dieselben Werkzeuge. Schalte nur eine Installation ein.
hooks/register.ts 519 lines1import type { Register, EngineInterface } from 'claude-code'
2import {
3 extractSection,
4 fallbackPrompt,
5 ownershipDenial,
6 parsePacket,
7 protocolPrompt,
8 renderPacket,
9 stripPacket,
10 toList,
11 type Packet,
12} from './protocol.ts'
13
14// What a role is made of. A role's own values (set through worker_send) win
15// over the roles file, which wins over the mod's options.
16type RoleDef = {
17 brief?: string
18 owns?: string[]
19 model?: string
20 effort?: string
21 allowedTools?: string[]
22 disallowedTools?: string[]
23}
24
25type Role = RoleDef & {
26 name: string
27 sessionId?: string
28 runs: number
29 isBusy: boolean
30 lastStatus?: Packet['status']
31 lastSummary?: string
32}
33
34type Job = { role: string; task: string; model?: string }
35
36const ROLE_NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/
37
38const config = {
39 claudeBin: 'claude',
40 permissionMode: 'acceptEdits',
41 workerModel: '',
42 workerEffort: 'default',
43 maxBudgetUsd: 0,
44 maxParallel: 3,
45 runTimeoutMin: 30,
46 allowedTools: [] as string[],
47 disallowedTools: [] as string[],
48 enforceOwns: true,
49 notifyMode: 'prompt',
50 packetDetail: 'full' as 'full' | 'lean',
51 fallbackCompress: true,
52 fallbackModel: 'claude-haiku-5-5',
53 relayDir: '.relay',
54 rolesFile: '.relay/roles.json',
55 gitignore: true,
56 keepRuns: 20,
57}
58
59let cwd = ''
60let roles: Record<string, Role> = {}
61let queue: Job[] = []
62let running = 0
63let unread: string[] = []
64let isGitignoreChecked = false
65// Set in a worker session: the paths it may write (empty: unrestricted).
66let workerOwns: string[] = []
67let workerCwd = ''
68
69function storeKey() {
70 return `roles:${cwd}`
71}
72
73async function save($: EngineInterface) {
74 await $.store.set(storeKey(), roles)
75}
76
77function showStatus($: EngineInterface) {
78 const busy = Object.values(roles).filter(r => r.isBusy).map(r => r.name)
79 const waiting = queue.length ? ` · ${queue.length} queued` : ''
80 $.ui.status(busy.length || queue.length ? `relay: ${busy.join(', ') || 'none'} working${waiting}` : undefined)
81}
82
83function slotOf(run: number) {
84 return config.keepRuns > 0 ? ((run - 1) % config.keepRuns) + 1 : run
85}
86
87function runPath(role: string, run: number, kind: 'full.md' | 'packet.json') {
88 return `${config.relayDir}/runs/${role}/${String(slotOf(run)).padStart(3, '0')}.${kind}`
89}
90
91function isRotatedOut(role: Role, run: number) {
92 return config.keepRuns > 0 && run <= role.runs - config.keepRuns
93}
94
95async function loadRoleDefs($: EngineInterface): Promise<Record<string, RoleDef>> {
96 try {
97 if (!(await $.fs.exists(config.rolesFile))) return {}
98 const parsed = JSON.parse(await $.fs.read(config.rolesFile)) as Record<string, unknown>
99 const map = (parsed.roles ?? parsed) as Record<string, Record<string, unknown>>
100 const defs: Record<string, RoleDef> = {}
101 for (const [name, d] of Object.entries(map)) {
102 if (!ROLE_NAME.test(name) || typeof d !== 'object' || d === null) continue
103 defs[name] = {
104 ...(typeof d.brief === 'string' ? { brief: d.brief } : {}),
105 ...(d.owns !== undefined ? { owns: toList(d.owns) } : {}),
106 ...(typeof d.model === 'string' ? { model: d.model } : {}),
107 ...(typeof d.effort === 'string' ? { effort: d.effort } : {}),
108 ...(d.allowedTools !== undefined ? { allowedTools: toList(d.allowedTools) } : {}),
109 ...(d.disallowedTools !== undefined ? { disallowedTools: toList(d.disallowedTools) } : {}),
110 }
111 }
112 return defs
113 } catch {
114 return {}
115 }
116}
117
118function effective(role: Role, def: RoleDef | undefined) {
119 const effort = role.effort ?? def?.effort ?? config.workerEffort
120 return {
121 brief: role.brief || def?.brief || '',
122 owns: role.owns?.length ? role.owns : (def?.owns ?? []),
123 model: role.model || def?.model || config.workerModel,
124 effort: effort === 'default' ? '' : effort,
125 allowedTools: role.allowedTools ?? def?.allowedTools ?? config.allowedTools,
126 disallowedTools: role.disallowedTools ?? def?.disallowedTools ?? config.disallowedTools,
127 }
128}
129
130async function ensureGitignore($: EngineInterface) {
131 if (isGitignoreChecked || !config.gitignore) return
132 isGitignoreChecked = true
133 const entry = `${config.relayDir.replace(/\\/g, '/').replace(/\/+$/, '')}/runs/`
134 try {
135 if (await $.fs.exists('.gitignore')) {
136 const text = await $.fs.read('.gitignore')
137 if (!text.split(/\r?\n/).some(l => l.trim() === entry)) {
138 await $.fs.write('.gitignore', `${text}${text.endsWith('\n') || !text ? '' : '\n'}${entry}\n`)
139 }
140 } else if (await $.fs.exists('.git')) {
141 await $.fs.write('.gitignore', `${entry}\n`)
142 }
143 } catch {
144 // not worth failing a run over
145 }
146}
147
148// Starts queued jobs while there is room and their role is idle.
149async function pump($: EngineInterface) {
150 while (running < Math.max(1, config.maxParallel)) {
151 const index = queue.findIndex(j => !roles[j.role]?.isBusy)
152 if (index < 0) break
153 const [job] = queue.splice(index, 1)
154 if (job) await start($, job)
155 }
156 showStatus($)
157}
158
159async function start($: EngineInterface, job: Job) {
160 const role = roles[job.role]
161 if (!role) return
162 const eff = effective(role, (await loadRoleDefs($))[role.name])
163 const isNew = role.sessionId === undefined
164 const sessionId = role.sessionId ?? crypto.randomUUID()
165 const run = role.runs + 1
166 const model = job.model || eff.model
167 const argv = [
168 config.claudeBin,
169 '-p',
170 '--output-format',
171 'json',
172 '--permission-mode',
173 config.permissionMode,
174 '--append-system-prompt',
175 protocolPrompt(role.name, eff.brief, eff.owns),
176 ...(isNew ? ['--session-id', sessionId] : ['--resume', sessionId]),
177 ...(model ? ['--model', model] : []),
178 ...(eff.effort ? ['--effort', eff.effort] : []),
179 ...(config.maxBudgetUsd > 0 ? ['--max-budget-usd', String(config.maxBudgetUsd)] : []),
180 ...(eff.allowedTools.length ? ['--allowedTools', ...eff.allowedTools] : []),
181 ...(eff.disallowedTools.length ? ['--disallowedTools', ...eff.disallowedTools] : []),
182 ]
183 const env: Record<string, string> = { RELAY_WORKER: '1', RELAY_CWD: cwd }
184 if (config.enforceOwns && eff.owns.length) env.RELAY_OWNS = JSON.stringify(eff.owns)
185
186 role.isBusy = true
187 role.runs = run
188 role.sessionId = sessionId
189 running++
190 await save($)
191 showStatus($)
192
193 // The child outlives the tool call that queued it: this loop runs on.
194 void (async () => {
195 let stdout = ''
196 let stderr = ''
197 let isTimedOut = false
198 const child = $.process.spawn({ argv, cwd, input: job.task, env })
199 const timer =
200 config.runTimeoutMin > 0
201 ? $.clock.after(config.runTimeoutMin * 60_000, () => {
202 isTimedOut = true
203 void child.return(undefined as never)
204 })
205 : undefined
206 try {
207 for await (const chunk of child) {
208 if (chunk.stream === 'stdout') stdout += chunk.text
209 else stderr += chunk.text
210 }
211 } catch (err) {
212 stderr += `\n${String(err)}`
213 }
214 timer?.cancel()
215 running--
216 try {
217 await finish($, role, run, job.task, stdout, stderr, isTimedOut)
218 } finally {
219 await pump($)
220 }
221 })()
222}
223
224async function finish(
225 $: EngineInterface,
226 role: Role,
227 run: number,
228 task: string,
229 stdout: string,
230 stderr: string,
231 isTimedOut: boolean,
232) {
233 let answer = ''
234 let meta = ''
235 try {
236 const json = JSON.parse(stdout) as {
237 result?: string
238 is_error?: boolean
239 subtype?: string
240 session_id?: string
241 num_turns?: number
242 total_cost_usd?: number
243 }
244 answer = json.result ?? ''
245 if (json.session_id) role.sessionId = json.session_id
246 meta = [
247 json.num_turns !== undefined ? `${json.num_turns} turns` : '',
248 json.total_cost_usd !== undefined ? `$${json.total_cost_usd.toFixed(2)}` : '',
249 json.is_error ? `worker error${json.subtype ? ` (${json.subtype})` : ''}` : '',
250 ]
251 .filter(Boolean)
252 .join(', ')
253 } catch {
254 answer = stdout
255 }
256 if (isTimedOut) meta = [meta, `stopped after ${config.runTimeoutMin} min`].filter(Boolean).join(', ')
257
258 let packet = parsePacket(answer)
259 if (packet) packet.compressed_by = 'worker'
260 else if (answer.trim() && config.fallbackCompress) {
261 const compressed = await $.model.complete({ model: config.fallbackModel, system: fallbackPrompt(role.name), prompt: answer })
262 packet = compressed.isAnswered ? parsePacket(compressed.text) : undefined
263 if (packet) packet.compressed_by = 'fallback'
264 }
265 packet ??= {
266 status: 'failed',
267 summary: isTimedOut
268 ? `Worker was stopped after ${config.runTimeoutMin} minutes without a packet.`
269 : answer.trim()
270 ? 'Worker answered without a relay packet (fallback compression is off); read the full text.'
271 : `Worker produced no usable answer.${stderr.trim() ? ` stderr: ${stderr.trim().slice(0, 500)}` : ''}`,
272 decisions: [],
273 facts: [],
274 artifacts: [],
275 open_questions: [],
276 needs_from_main: [],
277 risks: [],
278 next_suggested: [],
279 sections: [],
280 compressed_by: 'none',
281 }
282 if (isTimedOut && packet.status === 'done') packet.status = 'partial'
283
284 await ensureGitignore($)
285 const fullPath = runPath(role.name, run, 'full.md')
286 await $.fs.write(fullPath, `# ${role.name} · run ${run}\n\n## Task\n\n${task}\n\n## Answer\n\n${stripPacket(answer)}\n`)
287 await $.fs.write(runPath(role.name, run, 'packet.json'), JSON.stringify({ run, ...packet }, null, 2))
288
289 role.isBusy = false
290 role.lastStatus = packet.status
291 role.lastSummary = packet.summary
292 roles[role.name] = role
293 await save($)
294
295 const head = `[relay] ${role.name} · run ${run} finished${meta ? ` (${meta})` : ''}`
296 const text = renderPacket(head, packet, fullPath, config.packetDetail)
297 if (config.notifyMode === 'note') {
298 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
299 $.ui.toast(`relay: ${role.name} run ${run} ${packet.status}`)
300 } else if (config.notifyMode === 'toast') {
301 unread.push(text)
302 $.ui.toast(`relay: ${role.name} run ${run} ${packet.status} – worker_status shows it`)
303 } else {
304 await $.prompt.submit({ text })
305 }
306}
307
308// A worker may only write what it owns, when the main agent asked for that.
309function denyUnowned(path: string | undefined) {
310 const reason = ownershipDenial(path, workerCwd, workerOwns)
311 return reason ? { deny: `relay: ${reason}` } : undefined
312}
313
314export const register: Register = (on, options) => {
315 const str = (k: string, d: string) => (typeof options[k] === 'string' ? String(options[k]) : d)
316 const num = (k: string, d: number) => (typeof options[k] === 'number' ? Number(options[k]) : d)
317 const bool = (k: string, d: boolean) => (typeof options[k] === 'boolean' ? Boolean(options[k]) : d)
318 config.claudeBin = str('claudeBin', config.claudeBin)
319 config.permissionMode = str('permissionMode', config.permissionMode)
320 config.workerModel = str('workerModel', config.workerModel).trim()
321 config.workerEffort = str('workerEffort', config.workerEffort)
322 config.maxBudgetUsd = num('maxBudgetUsd', config.maxBudgetUsd)
323 config.maxParallel = num('maxParallel', config.maxParallel)
324 config.runTimeoutMin = num('runTimeoutMin', config.runTimeoutMin)
325 config.allowedTools = toList(options.allowedTools)
326 config.disallowedTools = toList(options.disallowedTools)
327 config.enforceOwns = bool('enforceOwns', config.enforceOwns)
328 config.notifyMode = str('notifyMode', config.notifyMode)
329 config.packetDetail = str('packetDetail', config.packetDetail) === 'lean' ? 'lean' : 'full'
330 config.fallbackCompress = bool('fallbackCompress', config.fallbackCompress)
331 config.fallbackModel = str('fallbackModel', config.fallbackModel)
332 config.relayDir = str('relayDir', config.relayDir).trim() || '.relay'
333 config.rolesFile = str('rolesFile', config.rolesFile).trim() || `${config.relayDir}/roles.json`
334 config.gitignore = bool('gitignore', config.gitignore)
335 config.keepRuns = num('keepRuns', config.keepRuns)
336
337 on('session.start', async ($, e, next) => {
338 const started = await next(e)
339 // A worker session must not become an orchestrator itself; it only
340 // carries the ownership guard.
341 if ((await $.env.get('RELAY_WORKER')) === '1') {
342 workerCwd = (await $.env.get('RELAY_CWD')) ?? e.cwd
343 try {
344 workerOwns = toList(JSON.parse((await $.env.get('RELAY_OWNS')) ?? '[]'))
345 } catch {
346 workerOwns = []
347 }
348 return started
349 }
350 cwd = e.cwd
351 roles = ((await $.store.get(storeKey())) as Record<string, Role> | undefined) ?? {}
352 // Children die with the module: a role still marked busy is stale.
353 for (const r of Object.values(roles)) r.isBusy = false
354 queue = []
355 running = 0
356 await save($)
357
358 await $.tool.register({
359 name: 'worker_send',
360 isDeferred: false,
361 description:
362 'Send a task to a persistent worker session (its own Claude Code session, started via CLI). ' +
363 'Returns immediately; the worker runs in the background (or waits in the queue) and its compact relay packet arrives later. ' +
364 'The same role keeps its context across tasks unless fresh is true. Roles may be predefined in the roles file; ' +
365 'brief/owns/model/effort/tools given here are stored for the role and win over the file.',
366 inputSchema: {
367 type: 'object',
368 properties: {
369 role: { type: 'string', description: 'Role name, e.g. ingester, architect, researcher (lowercase).' },
370 task: { type: 'string', description: 'The task, self-contained: what to do and what done looks like.' },
371 brief: { type: 'string', description: 'Standing role description; stored.' },
372 owns: { type: 'array', items: { type: 'string' }, description: 'Paths this role may write; stored and enforced.' },
373 model: { type: 'string', description: 'Model for this role; stored.' },
374 effort: { type: 'string', enum: ['default', 'low', 'medium', 'high', 'xhigh', 'max'], description: 'Effort for this role; stored.' },
375 allowedTools: { type: 'array', items: { type: 'string' }, description: 'Tool rules allowed without asking; stored.' },
376 disallowedTools: { type: 'array', items: { type: 'string' }, description: 'Tool rules the role may never use; stored.' },
377 fresh: { type: 'boolean', description: 'Start a new worker session, dropping the old context.' },
378 },
379 required: ['role', 'task'],
380 },
381 })
382 await $.tool.register({
383 name: 'worker_status',
384 isDeferred: false,
385 description:
386 'List worker roles (stored and from the roles file): working, queued or idle, runs, last status. ' +
387 'Also returns packets that arrived unread (notify mode "toast").',
388 inputSchema: { type: 'object', properties: {} },
389 })
390 await $.tool.register({
391 name: 'worker_expand',
392 isDeferred: false,
393 description:
394 "Fetch details behind a relay packet: one section of a worker run's full answer by heading, " +
395 'section "packet" for the complete packet, or the whole answer. Prefer a section; the full text costs context.',
396 inputSchema: {
397 type: 'object',
398 properties: {
399 role: { type: 'string' },
400 run: { type: 'number', description: 'Run number; latest when absent.' },
401 section: { type: 'string', description: 'Heading to fetch (as listed under sections), or "packet".' },
402 maxChars: { type: 'number', description: 'Cut-off, default 12000.' },
403 },
404 required: ['role'],
405 },
406 })
407 showStatus($)
408 return started
409 })
410
411 on('tool.call', { tool: 'Edit' }, ($, e, next) => denyUnowned(e.file_path) ?? next(e)).catch(($, e, next) =>
412 next.called ? next(e) : { deny: 'relay: the ownership guard failed.' },
413 )
414 on('tool.call', { tool: 'Write' }, ($, e, next) => denyUnowned(e.file_path) ?? next(e)).catch(($, e, next) =>
415 next.called ? next(e) : { deny: 'relay: the ownership guard failed.' },
416 )
417 on('tool.call', { tool: 'NotebookEdit' }, ($, e, next) => denyUnowned(e.notebook_path) ?? next(e)).catch(($, e, next) =>
418 next.called ? next(e) : { deny: 'relay: the ownership guard failed.' },
419 )
420
421 on('tool.call', { tool: 'mcp__relay__worker_status' }, async $ => {
422 const defs = await loadRoleDefs($)
423 const names = [...new Set([...Object.keys(roles), ...Object.keys(defs)])].sort()
424 const lines = names.map(name => {
425 const r = roles[name]
426 const eff = effective(r ?? { name, runs: 0, isBusy: false }, defs[name])
427 const queued = queue.filter(j => j.role === name).length
428 const state = r?.isBusy ? 'working' : queued ? 'queued' : r ? 'idle' : 'defined, never run'
429 return (
430 `${name}: ${state}${queued ? ` (${queued} waiting)` : ''}, ${r?.runs ?? 0} run(s)` +
431 (r?.lastStatus ? `, last ${r.lastStatus}: ${r.lastSummary ?? ''}` : '') +
432 (eff.owns.length ? ` · owns ${eff.owns.join(', ')}` : '') +
433 (eff.model ? ` · ${eff.model}` : '')
434 )
435 })
436 const out = [
437 lines.length ? lines.join('\n') : 'No worker roles yet. Create one with worker_send or in the roles file.',
438 `Running ${running}/${Math.max(1, config.maxParallel)}, queued ${queue.length}.`,
439 ]
440 if (unread.length) {
441 out.push('', `Unread packets (${unread.length}):`, '', unread.join('\n\n'))
442 unread = []
443 }
444 return { result: out.join('\n') }
445 })
446
447 on('tool.call', { tool: 'mcp__relay__worker_expand' }, async ($, e) => {
448 const input = e as unknown as { role: string; run?: number; section?: string; maxChars?: number }
449 const role = roles[input.role]
450 if (!role || role.runs === 0) return { result: `No runs for role "${input.role}".` }
451 const run = input.run ?? (role.isBusy ? role.runs - 1 : role.runs)
452 if (run < 1) return { result: `${role.name} has no finished run yet.` }
453 if (isRotatedOut(role, run)) return { result: `${role.name} run ${run} was rotated out (keepRuns ${config.keepRuns}).` }
454 if (input.section?.trim().toLowerCase() === 'packet') {
455 try {
456 const packet = parsePacket(await $.fs.read(runPath(role.name, run, 'packet.json')))
457 if (packet) return { result: renderPacket(`[relay] ${role.name} · run ${run}`, packet, runPath(role.name, run, 'full.md'), 'full') }
458 } catch {
459 // fall through
460 }
461 return { result: `No stored packet for ${role.name} run ${run}.` }
462 }
463 let text: string
464 try {
465 text = await $.fs.read(runPath(role.name, run, 'full.md'))
466 } catch {
467 return { result: `No stored answer for ${role.name} run ${run}.` }
468 }
469 let out = text
470 if (input.section) {
471 const found = extractSection(text, input.section)
472 if (!found) return { result: `No section "${input.section}" in ${role.name} run ${run}.` }
473 out = found
474 }
475 const max = input.maxChars ?? 12000
476 return { result: out.length > max ? `${out.slice(0, max)}\n… [cut at ${max} of ${out.length} chars]` : out }
477 })
478
479 on('tool.call', { tool: 'mcp__relay__worker_send' }, async ($, e) => {
480 const input = e as unknown as {
481 role: string
482 task: string
483 brief?: string
484 owns?: string[]
485 model?: string
486 effort?: string
487 allowedTools?: string[]
488 disallowedTools?: string[]
489 fresh?: boolean
490 }
491 const name = input.role.trim().toLowerCase()
492 if (!ROLE_NAME.test(name)) return { result: `Invalid role name "${input.role}": use a-z, 0-9, - and _.` }
493 const role: Role = roles[name] ?? { name, runs: 0, isBusy: false }
494 if (input.brief !== undefined) role.brief = input.brief
495 if (input.owns !== undefined) role.owns = toList(input.owns)
496 if (input.model !== undefined) role.model = input.model
497 if (input.effort !== undefined) role.effort = input.effort
498 if (input.allowedTools !== undefined) role.allowedTools = toList(input.allowedTools)
499 if (input.disallowedTools !== undefined) role.disallowedTools = toList(input.disallowedTools)
500 if (input.fresh) {
501 if (role.isBusy || queue.some(j => j.role === name)) return { result: `${name} has work in flight; fresh is only possible when it is idle.` }
502 role.sessionId = undefined
503 }
504 roles[name] = role
505 await save($)
506
507 queue.push({ role: name, task: input.task })
508 const before = running
509 await pump($)
510 const isStarted = running > before && role.isBusy && !queue.some(j => j.role === name)
511 return {
512 result: isStarted
513 ? `${name} run ${role.runs} started. Its relay packet will arrive later (${config.notifyMode}); continue with other work.`
514 : `${name}: task queued (position ${queue.findIndex(j => j.role === name) + 1} of ${queue.length}; ` +
515 `${role.isBusy ? 'the role is still working' : `all ${config.maxParallel} slots are busy`}).`,
516 }
517 })
518}
519hooks/protocol.ts 195 lines1// The relay protocol: what a worker must end its answer with, and how the
2// main agent reads it. Pure functions only, so they can be tested.
3
4export type Artifact = { path: string; change: 'created' | 'modified' | 'deleted' | 'read' | 'refused'; note?: string }
5export type Fact = { id: string; text: string }
6
7export type Packet = {
8 status: 'done' | 'partial' | 'blocked' | 'failed'
9 summary: string
10 decisions: string[]
11 facts: Fact[]
12 artifacts: Artifact[]
13 open_questions: string[]
14 needs_from_main: string[]
15 risks: string[]
16 next_suggested: string[]
17 sections: string[]
18 compressed_by?: 'worker' | 'fallback' | 'none'
19}
20
21const LIST_FIELDS = ['decisions', 'open_questions', 'needs_from_main', 'risks', 'next_suggested', 'sections'] as const
22
23export const PACKET_SCHEMA = `{
24 "status": "done | partial | blocked | failed",
25 "summary": "max 3 sentences: what was achieved",
26 "decisions": ["every decision you took that others must respect"],
27 "facts": [{ "id": "F1", "text": "a finding the main agent needs: numbers, names, IDs, URLs verbatim" }],
28 "artifacts": [{ "path": "relative/path", "change": "created | modified | deleted | read | refused (a write that was blocked)", "note": "what is in it, or why it was refused" }],
29 "open_questions": ["things you could not resolve"],
30 "needs_from_main": ["requests for decisions or work outside your ownership"],
31 "risks": ["conflicts, assumptions, things that may break"],
32 "next_suggested": ["sensible next tasks"],
33 "sections": ["the exact headings of your answer above, so details can be fetched on demand"]
34}`
35
36export function protocolPrompt(role: string, brief: string, owns: readonly string[]): string {
37 const ownership = owns.length
38 ? `You may only create or modify files under: ${owns.join(', ')}. Anything outside goes into "needs_from_main".`
39 : 'Ownership is not restricted, but list every file you touch under "artifacts".'
40 return [
41 `You are the "${role}" worker in a relay of Claude Code sessions, steered by a main agent.`,
42 brief ? `Your role: ${brief}` : '',
43 ownership,
44 'The main agent will NOT read your full answer by default; it reads only your relay packet and fetches sections on demand.',
45 'Write your full answer first, structured with markdown headings. Then end with exactly one fenced block tagged relay holding JSON of this shape:',
46 '```relay',
47 PACKET_SCHEMA,
48 '```',
49 'Packet rules: never drop a decision, a file change, a blocker, an error, or an exact identifier (numbers, names, paths, URLs, versions).',
50 'Compress wording, not content. Leave out reasoning, narration and anything the main agent can fetch from your sections.',
51 'Use empty lists rather than omitting fields. Write the packet in the language of the task.',
52 ].filter(Boolean).join('\n')
53}
54
55export function fallbackPrompt(role: string): string {
56 return [
57 `Compress the following answer of the "${role}" worker into a relay packet.`,
58 'Never drop a decision, a file change, a blocker, an error, or an exact identifier. Compress wording, not content.',
59 'Answer with JSON only, of this shape:',
60 PACKET_SCHEMA,
61 ].join('\n')
62}
63
64// Finds the last ```relay block (or a bare JSON object as a fallback) and
65// normalises it; undefined when nothing parseable is there.
66export function parsePacket(text: string): Packet | undefined {
67 const fenced = [...text.matchAll(/```relay\s*\n([\s\S]*?)```/g)]
68 const candidates = fenced.length
69 ? [fenced.at(-1)?.[1] ?? '']
70 : [text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)]
71 for (const raw of candidates) {
72 try {
73 return normalise(JSON.parse(raw))
74 } catch {
75 // not JSON: fall through
76 }
77 }
78 return undefined
79}
80
81function normalise(value: unknown): Packet | undefined {
82 if (typeof value !== 'object' || value === null) return undefined
83 const v = value as Record<string, unknown>
84 const status = ['done', 'partial', 'blocked', 'failed'].includes(String(v.status))
85 ? (v.status as Packet['status'])
86 : 'partial'
87 const strings = (x: unknown) => (Array.isArray(x) ? x.map(String) : [])
88 const packet: Packet = {
89 status,
90 summary: typeof v.summary === 'string' ? v.summary : '',
91 decisions: [],
92 facts: Array.isArray(v.facts)
93 ? v.facts.map((f, i) =>
94 typeof f === 'string' ? { id: `F${i + 1}`, text: f } : { id: String(f?.id ?? `F${i + 1}`), text: String(f?.text ?? '') },
95 )
96 : [],
97 artifacts: Array.isArray(v.artifacts)
98 ? v.artifacts
99 .filter(a => a && typeof a.path === 'string')
100 .map(a => ({ path: a.path, change: a.change ?? 'modified', ...(a.note ? { note: String(a.note) } : {}) }))
101 : [],
102 open_questions: [],
103 needs_from_main: [],
104 risks: [],
105 next_suggested: [],
106 sections: [],
107 }
108 for (const field of LIST_FIELDS) packet[field] = strings(v[field])
109 return packet
110}
111
112// Strips the relay block from the full answer, so the stored full text is
113// what the worker wrote for humans.
114export function stripPacket(text: string): string {
115 return text.replace(/```relay\s*\n[\s\S]*?```\s*$/, '').trimEnd()
116}
117
118// The packet as the main agent reads it: dense, every essential kept.
119// `lean` keeps what the main agent must act on and counts the rest, which
120// worker_expand with section "packet" returns in full.
121export function renderPacket(head: string, p: Packet, fullPath: string, detail: 'full' | 'lean' = 'full'): string {
122 const lines = [head, `Status: ${p.status}${p.compressed_by === 'fallback' ? ' (packet built by fallback compressor)' : ''}`]
123 if (p.summary) lines.push(`Summary: ${p.summary}`)
124 const list = (title: string, items: string[]) => {
125 if (items.length) lines.push(`${title}:`, ...items.map(i => `- ${i}`))
126 }
127 list('Decisions', p.decisions)
128 if (detail === 'full') list('Facts', p.facts.map(f => `${f.id}: ${f.text}`))
129 list('Artifacts', p.artifacts.map(a => `${a.path} (${a.change})${a.note ? ` – ${a.note}` : ''}`))
130 list('Needs from main', p.needs_from_main)
131 list('Open questions', p.open_questions)
132 if (detail === 'full') {
133 list('Risks', p.risks)
134 list('Next suggested', p.next_suggested)
135 } else {
136 const held = [
137 p.facts.length ? `${p.facts.length} facts` : '',
138 p.risks.length ? `${p.risks.length} risks` : '',
139 p.next_suggested.length ? `${p.next_suggested.length} next steps` : '',
140 ].filter(Boolean)
141 if (held.length) lines.push(`Held back: ${held.join(', ')} → worker_expand section "packet"`)
142 }
143 lines.push(`Full text: ${fullPath}${p.sections.length ? ` · sections: ${p.sections.join(' | ')}` : ''} → worker_expand`)
144 return lines.join('\n')
145}
146
147// Whether a worker may write `path`: it must lie inside the worker's cwd and
148// under one of the owned paths. Returns the reason to refuse, or undefined.
149export function ownershipDenial(path: string | undefined, cwd: string, owns: readonly string[]): string | undefined {
150 if (!owns.length || !path) return undefined
151 const norm = (s: string) => s.replace(/\\/g, '/').replace(/\/+$/, '')
152 const p = norm(path)
153 const base = norm(cwd)
154 let rel: string
155 if (p.toLowerCase().startsWith(`${base.toLowerCase()}/`)) rel = p.slice(base.length + 1)
156 else if (/^([a-zA-Z]:)?\//.test(p)) return `${path} lies outside the project; this worker may only write under ${owns.join(', ')}.`
157 else rel = p.replace(/^\.\//, '')
158 if (rel.split('/').includes('..')) return `${path} leaves the owned paths (..); this worker may only write under ${owns.join(', ')}.`
159 const relLower = rel.toLowerCase()
160 const isOwned = owns.some(o => {
161 const own = norm(o).replace(/^\.\//, '').toLowerCase()
162 return relLower === own || relLower.startsWith(`${own}/`)
163 })
164 return isOwned
165 ? undefined
166 : `${rel} is not owned by this worker (owns: ${owns.join(', ')}). Put the change into "needs_from_main" instead.`
167}
168
169// "a, b c" or a list → ["a", "b", "c"]; tool rules with spaces stay whole
170// when given as a list.
171export function toList(value: unknown): string[] {
172 if (Array.isArray(value)) return value.map(String).map(s => s.trim()).filter(Boolean)
173 if (typeof value !== 'string') return []
174 return value.split(/[,\n]/).map(s => s.trim()).filter(Boolean)
175}
176
177// One markdown section by heading (case-insensitive substring), up to the
178// next heading of the same or a higher level.
179export function extractSection(text: string, heading: string): string | undefined {
180 const lines = text.split('\n')
181 const needle = heading.toLowerCase().replace(/^#+\s*/, '')
182 const start = lines.findIndex(l => /^#{1,6}\s/.test(l) && l.toLowerCase().includes(needle))
183 if (start < 0) return undefined
184 const level = (lines[start] ?? '').match(/^#+/)?.[0].length ?? 1
185 let end = lines.length
186 for (let i = start + 1; i < lines.length; i++) {
187 const m = (lines[i] ?? '').match(/^(#{1,6})\s/)
188 if (m && (m[1] ?? '').length <= level) {
189 end = i
190 break
191 }
192 }
193 return lines.slice(start, end).join('\n')
194}
195