SLOPSHOPPER

relay

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

newguardtoaststatustoolmodel
★ 1v0.3.0MITupdated 2026-10-26LupusMalusDeviant/mod-box/plugins/relay
A shopper browsing a rack in a slop shop
README

relay

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.

Wie es funktioniert

            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)
  • Rolle: ein Worker mit Namen, Aufgabenbeschreibung (brief) und eigenem Schreibbereich (owns). Eine Rolle behält ihre Session über viele Aufgaben hinweg und erinnert sich an frühere Läufe.
  • Lauf: eine Aufgabe für eine Rolle. Er läuft im Hintergrund als claude -p. Der Main-Agent arbeitet währenddessen weiter.
  • Relay-Paket: das Ergebnis eines Laufs. Es enthält Status, Zusammenfassung, Entscheidungen, Fakten, geänderte Dateien, offene Fragen, was der Main-Agent tun oder entscheiden muss, Risiken und nächste Schritte.
  • Ergebnisse laufen immer über den Main-Agent. Worker sprechen nie direkt miteinander. Damit ist klar, wer was weiß und wer was entscheidet.

Die drei Werkzeuge des Main-Agents

WerkzeugWozu
worker_sendGibt einer Rolle eine Aufgabe. Startet sofort oder reiht sie in die Warteschlange ein.
worker_statusZeigt, welche Rolle arbeitet, wartet oder fertig ist, und was zuletzt rauskam.
worker_expandHolt 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.“

Einstellungen

Einstellbar in der Mod Box unter ⚙ oder mit /plugin configure relay@<marketplace>.

Worker starten

EinstellungStandardWas sie bewirkt
Claude executable (claudeBin)claudeDas 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)acceptEditsWas 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-5Das 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)mediumWie gründlich Worker nachdenken. Höher heißt besser, aber langsamer und teurer. default übernimmt die Einstellung der CLI. Pro Rolle überschreibbar.

Kosten und Grenzen

EinstellungStandardWas sie bewirkt
Budget per run (USD) (maxBudgetUsd)2Höchstbetrag pro Lauf. Ist er erreicht, bricht der Worker ab und meldet partial. 0 = keine Grenze.
Parallel workers (maxParallel)3Wie viele Worker gleichzeitig laufen. Weitere Aufgaben warten und starten von selbst, sobald ein Platz frei wird.
Timeout per run (minutes) (runTimeoutMin)30Ein Lauf, der länger dauert, wird beendet und als partial oder failed gemeldet. 0 = kein Timeout.

Sicherheit

EinstellungStandardWas sie bewirkt
Allowed tools (allowedTools)leerWerkzeuge, 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)anEin 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.

Wie Ergebnisse ankommen

EinstellungStandardWas sie bewirkt
Packet delivery (notifyMode)promptprompt: 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)fullfull: 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)anVergisst 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-5Das Modell für diesen Ersatz. Kostet nur, wenn ein Paket fehlt.

Ablage

EinstellungStandardWas sie bewirkt
Relay folder (relayDir).relayProjektordner für die Volltexte (runs/) und die Rollen-Datei.
Roles file (rolesFile).relay/roles.jsonDatei mit fest definierten Rollen (siehe unten). Sie gehört ins Repository.
Ignore run files in git (gitignore)anTrägt in Git-Projekten .relay/runs/ in die .gitignore ein. Die Rollen-Datei bleibt versioniert.
Runs kept per role (keepRuns)20Wie viele Läufe pro Rolle auf der Platte bleiben. Ältere werden überschrieben. 0 = alle behalten.

Rollen fest definieren

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.

Grenzen

  • Worker laufen als Unterprozesse der Session. Wird das Plugin neu geladen oder endet die Session, werden laufende Worker beendet und wartende Aufgaben verworfen. Die Sessions der Rollen bleiben aber gespeichert, der nächste Auftrag macht mit dem alten Kontext weiter.
  • Worker können dir keine Fragen stellen. Alles, was eine Entscheidung braucht, kommt im Paket unter „Needs from main“ zurück.
  • Jeder Lauf ist eine vollständige Claude-Code-Session und kostet entsprechend. Für kurze Recherchen sind normale Subagents günstiger.
  • Worker haben selbst keine Relay-Werkzeuge und können keine weiteren Worker starten.

Installation

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.

Source 2 files
hooks/register.ts 519 lines
1import 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}
519
hooks/protocol.ts 195 lines
1// 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