SLOPSHOPPER

sentinel-swarm

An agent swarm, Oracle, Manager, Lead, Coder, and an optional Driver, that takes a PRD to built, tested, reviewed code. Enforced reviews, a shared ledger, and…

guardprocessnetworktimer
v0.3.1LicenseRef-No-Resale-1.0updated 2026-10-24Alexk413x/sentinel-swarm
A shopper browsing a rack in a slop shop
README

sentinel-swarm

A Claude Code plugin that runs an agent swarm to take a PRD to built, tested, reviewed code. An Oracle plans the run, Managers own phases, Leads own modules, and Coders write one file each. A Driver tests the app between waves when the host has cartographer and a driver plugin. Enforced reviews, a shared ledger, and a scoring rubric hold every role accountable, and every hand-off requires recorded evidence, not a claim.

Install

claude plugin marketplace add Alexk413x/marketplace
claude plugin install codebase-kg@alexk413x --scope project
claude plugin install sentinel-swarm@alexk413x --scope project

Run the install commands from the repo the swarm builds in. sentinel-swarm depends on codebase-kg, and Claude Code refuses to load sentinel-swarm in a project where codebase-kg is not installed. Every role queries the code graph before it plans or writes, and each Coder updates the graph for its own file.

Pre-1.0. A small run works from start to finish: the hello-world smoke test passes with every role as its own background session, and a stopped session resumes and finishes its work. The hook transport runs on function hooks, an early-access Claude Code API that changes between releases.

Requirements

  • Claude Code 2.1.294 or later: the CLI, the desktop app, or an IDE extension, on Windows or macOS. claude.ai chat and Cowork cannot run a swarm, because every role is a local background session with its own hooks.
  • Python 3.9 or newer on PATH as py, python3, or python. The ledger imports only the standard library, so it needs no venv. On a Python older than codebase-kg's floor (3.10), graph writes run codebase-kg through uv, so install uv too.
  • git.
  • A trusted host folder: run claude in it once and accept the trust prompt. claude --bg refuses an untrusted folder.
  • Optional: cartographer and a driver plugin (android-driver, ios-driver, or web-driver) for the Driver. Without them the run finishes on unit tests alone.

Run a swarm

  1. Run /sentinel-swarm:setup in the host repo. It writes the role files to .claude/agents/swarm-<role>.md, the hook shim to .sentinel-swarm/hook.py, the settings file, and the git excludes, and it builds the code graph when there is none.
  2. Run /sentinel-swarm:run with the PRD. It starts the repo's ledger server and the Oracle as a background session.
  3. Watch the run in agent view: every role is its own session. /sentinel-swarm:status reads the run's state, and /sentinel-swarm:resume picks up a stopped run.

The run and resume skills only run when you ask for them. The swarm works in your current working tree and commits nothing.

Roles

RoleColorDefault modelCreated by
OraclecyanopusThe user
ManagergreenopusOracle
LeadpurplesonnetManager
CoderorangesonnetLead
DriveryellowsonnetOracle

Each parent-child pair follows the same cycle: brief, work, self-review, hand up, review, then approve, return, or accept as incomplete. Only the Coder writes project files. A parent starts its child with the ledger tool agent_spawn; no role has the Agent tool except the Driver, which may run only cartographer's agents.

What it runs, reads and writes

  • Runs: claude --bg sessions for every role; one ledger server per host repo, on a loopback port with a bearer token; the host's test, build, and lint commands; git; and codebase-kg's tools.
  • Reads: the host repo, .claude/sentinel-swarm.local.md, and Claude Code's plugin registry, to find the installed plugins.
  • Writes: the role files in .claude/agents/, the run's records in .sentinel-swarm/ (the ledger database, the server's port and token, and the report), and the project files the Coders write. It shows desktop notifications unless notify turns them off.

Develop

The ledger is a Python project under mcp/. Run every check before you commit:

cd mcp
uv sync
uv run pytest
uv run pyright
uv run ruff check
uv run ruff format --check
cd ..
claude plugin test .
claude plugin validate --strict .claude-plugin/plugin.json

scripts/smoke.sh runs a hello-world swarm in a scratch host repo under runs/hello/. knowledge/prd/README.md indexes the specification, one document per subject; the code must match it. Run sh .githooks/install.sh once per clone.

Licence

Free to use, including at work, and free to fork and share. You may not sell it, a fork of it, or paid setup or hosting of it, and you may not use it for fraud or scams. See LICENSE for the full terms.

Source 2 files
hooks/register.ts 351 lines
1import type { EngineInterface, Register, SessionSendAddress } from 'claude-code'
2
3import type { SentinelSwarmSession } from '../types'
4
5type Answer = Record<string, unknown> | null
6type Owed = {
7  session_id: string
8  text: string
9  wakeup_ids: number[]
10}
11
12const ROLE_TYPE = /^swarm-(oracle|manager|lead|coder|driver)$/
13const LEDGER_TOOL = /^mcp__swarm-ledger__/
14const TOKEN = /^[A-Za-z0-9_-]{32,128}$/
15const REPO_HEADER = 'X-Sentinel-Swarm-Repo'
16const TRANSPORT_KEY = 'sentinel_swarm_transport'
17const FIRED_AT_KEY = 'sentinel_swarm_fired_at'
18const RECORDS = '.sentinel-swarm'
19const SHIM = `${RECORDS}/hook.py`
20const PYTHONS = ['python3', 'python']
21const FAST_MS = 10_000
22const SLOW_MS: Readonly<Record<string, number>> = { stop: 40_000, session_end: 40_000 }
23const SHIM_MS = 50_000
24const SEND_MS = 15_000
25const SEND_RETRY_MS = [2_000, 4_000, 8_000]
26const NOT_YET_LIVE = /no live session/i
27const WRITE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
28const WRITE_TOOL = new RegExp(`^(${WRITE_TOOLS.join('|')})$`)
29const SHELL_TOOL = /^(Bash|PowerShell)$/
30// Must equal SYNC_POST_TOOLS in mcp/src/swarm_ledger/hooks/events.py.
31const SYNC_POST_TOOLS = ['SendMessage', 'PushNotification', 'Monitor', ...WRITE_TOOLS]
32const RECEIVED_FROM = ['peer', 'peer-send-message', 'coordinator']
33const PRE_TOOL_GATES: readonly {
34  tool: string | RegExp
35  event: string
36  role?: string
37  skip?: (e: object) => boolean
38}[] = [
39  { tool: 'Agent', event: 'pre_agent' },
40  { tool: WRITE_TOOL, event: 'pre_write' },
41  { tool: SHELL_TOOL, event: 'pre_shell' },
42  { tool: 'Monitor', event: 'pre_monitor' },
43  // $.session.send runs as a SendMessage call to the target's pipe address, not its session id;
44  // the ledger checked the recipient before the mod sent, so the mod's own text passes.
45  { tool: 'SendMessage', event: 'pre_send_message', skip: isOwnSend },
46  { tool: 'Skill', event: 'pre_skill', role: 'driver' },
47  { tool: LEDGER_TOOL, event: 'pre_ledger' },
48]
49const sessionRef = { plugin: 'sentinel-swarm', key: 'session' } as const
50
51let session: SentinelSwarmSession | null | undefined
52const sending = new Set<number>()
53const paying = new Set<Promise<void>>()
54const sendingTexts = new Map<string, number>()
55
56export const register: Register = on => {
57  on('classic.SessionStart', async ($, e, next) => {
58    const s = await adopt($, e)
59    if (s === null) return next(e)
60    const answer = await runHook($, s, 'session_start', { ...e, [TRANSPORT_KEY]: 'mod' }).catch(() => null)
61    const result = await next(e)
62    const text = field(answer, 'additionalContext')
63    return text === undefined ? result : { ...result, additionalContext: [...(result.additionalContext ?? []), text] }
64  })
65
66  for (const { tool, event, role, skip } of PRE_TOOL_GATES) {
67    on('tool.call', { tool }, ($, e, next) => (skip?.(e) ? next(e) : judge($, e, next, event, role))).catch(($, e, next) =>
68      next.called ? next(e) : { deny: cannotCheck(next.error) },
69    )
70  }
71
72  on('classic.PostToolUse', async ($, e, next) => {
73    const s = await active($)
74    if (s === null) return next(e)
75    const payload = { ...e, [TRANSPORT_KEY]: 'mod' }
76    if (SYNC_POST_TOOLS.includes(e.tool_name)) {
77      await runHook($, s, 'post_any', payload).catch(() => null)
78    } else {
79      void runHook($, s, 'post_activity', { ...payload, [FIRED_AT_KEY]: new Date().toISOString() }).catch(() => null)
80    }
81    if (s.role === 'coder' && SHELL_TOOL.test(e.tool_name)) {
82      await runHook($, s, 'post_shell', payload).catch(() => null)
83    }
84    if (LEDGER_TOOL.test(e.tool_name)) void pay($, s)
85    return next(e)
86  })
87
88  on('classic.PreCompact', async ($, e, next) => {
89    const s = await active($)
90    if (s !== null) await runHook($, s, 'pre_compact', { ...e, [TRANSPORT_KEY]: 'mod' }).catch(() => null)
91    return next(e)
92  })
93
94  on('classic.Stop', async ($, e, next) => {
95    const s = await active($)
96    if (s === null) return next(e)
97    await Promise.all(paying)
98    await pay($, s)
99    const answer = await runHook($, s, 'stop', { ...e, [TRANSPORT_KEY]: 'mod' }).catch(() => null)
100    const result = await next(e)
101    const reason = isRecord(answer) && answer.decision === 'block' ? String(answer.reason ?? '') : undefined
102    return reason ? { ...result, block: reason } : result
103  })
104
105  on('classic.SessionEnd', async ($, e, next) => {
106    const s = await active($)
107    if (s !== null) await runHook($, s, 'session_end', { ...e, [TRANSPORT_KEY]: 'mod' }).catch(() => null)
108    return next(e)
109  })
110
111  on('session.receive', async ($, e, next) => {
112    const s = await active($)
113    if (s === null || e.agentId !== undefined || !RECEIVED_FROM.includes(e.origin.kind)) return next(e)
114    const taken = await post($, s, 'inbox_take', base(s)).catch(() => null)
115    const claim = isRecord(taken) && typeof taken.claim === 'string' ? taken.claim : null
116    if (claim === null) return next(e)
117    let result: Awaited<ReturnType<typeof next>>
118    try {
119      result = await next({ ...e, text: withInbox(e.text, taken) })
120    } catch (err) {
121      await post($, s, 'inbox_release', { ...base(s), claim }).catch(() => null)
122      throw err
123    }
124    const settle = result.consumed === undefined ? 'inbox_ack' : 'inbox_release'
125    await post($, s, settle, { ...base(s), claim }).catch(() => null)
126    return result
127  }).catch(($, e, next) => next(e))
128}
129
130async function judge<E extends object, R>(
131  $: EngineInterface,
132  e: E,
133  next: (e: E) => Promise<R>,
134  event: string,
135  role?: string,
136): Promise<R | { deny: string }> {
137  const s = await active($)
138  if (s === null || (role !== undefined && s.role !== role)) return next(e)
139  const { tool, tool_use_id, agentId, ...input } = e as Record<string, unknown>
140  const payload = {
141    ...base(s),
142    ...(typeof agentId === 'string' ? { agent_id: agentId, agent_type: undefined } : {}),
143    hook_event_name: 'PreToolUse',
144    tool_name: tool,
145    tool_input: input,
146    tool_use_id,
147  }
148  let answer: Answer
149  try {
150    answer = await runHook($, s, event, payload)
151  } catch (err) {
152    return { deny: cannotCheck(err) }
153  }
154  const reason = field(answer, 'permissionDecisionReason', 'deny')
155  if (reason !== undefined) return { deny: reason }
156  const updated = hookOutput(answer)?.updatedInput
157  if (!isRecord(updated)) return next(e)
158  return next({ tool, tool_use_id, ...updated } as E)
159}
160
161async function adopt(
162  $: EngineInterface,
163  e: { session_id: string; transcript_path: string; cwd: string; agent_type?: string; agent_id?: string },
164): Promise<SentinelSwarmSession | null> {
165  const role = ROLE_TYPE.exec(e.agent_type ?? '')?.[1]
166  const found: SentinelSwarmSession | null =
167    role !== undefined && e.agent_id === undefined
168      ? { sessionId: e.session_id, agentType: e.agent_type ?? '', role, cwd: e.cwd, transcriptPath: e.transcript_path }
169      : null
170  session = found
171  await $.state.set(sessionRef, found).catch(() => undefined)
172  return found
173}
174
175async function active($: EngineInterface): Promise<SentinelSwarmSession | null> {
176  if (session === undefined) {
177    const held = await $.state.get(sessionRef).catch(() => undefined)
178    session = held?.value ?? null
179  }
180  return session
181}
182
183function base(s: SentinelSwarmSession): Record<string, unknown> {
184  return {
185    session_id: s.sessionId,
186    transcript_path: s.transcriptPath,
187    cwd: s.cwd,
188    agent_type: s.agentType,
189    [TRANSPORT_KEY]: 'mod',
190  }
191}
192
193async function runHook($: EngineInterface, s: SentinelSwarmSession, event: string, payload: Record<string, unknown>): Promise<Answer> {
194  try {
195    return await post($, s, event, payload)
196  } catch {
197    return viaShim($, s, event, payload)
198  }
199}
200
201async function post($: EngineInterface, s: SentinelSwarmSession, event: string, payload: Record<string, unknown>): Promise<Answer> {
202  const [port, token] = await Promise.all([serverPort($, s.cwd), serverToken($, s.cwd)])
203  const answer = await within(
204    $,
205    SLOW_MS[event] ?? FAST_MS,
206    $.http.fetch(`http://127.0.0.1:${port}/hook/${event}`, {
207      method: 'POST',
208      headers: {
209        'Content-Type': 'application/json',
210        Authorization: `Bearer ${token}`,
211        [REPO_HEADER]: encodeURIComponent(s.cwd),
212      },
213      body: JSON.stringify(payload),
214    }),
215  )
216  if (answer.status !== 200 || !(REPO_HEADER.toLowerCase() in answer.headers)) {
217    throw new Error(`the ledger server answered ${answer.status}`)
218  }
219  return parse(answer.text)
220}
221
222async function viaShim($: EngineInterface, s: SentinelSwarmSession, event: string, payload: Record<string, unknown>): Promise<Answer> {
223  let reason = 'no Python interpreter ran the shim'
224  for (const python of PYTHONS) {
225    const ran = await $.process
226      .run([python, SHIM, 'hook', event], { cwd: s.cwd, stdin: JSON.stringify(payload), timeoutMs: SHIM_MS })
227      .catch((err: unknown) => {
228        reason = errorText(err)
229        return undefined
230      })
231    if (ran === undefined) continue
232    if (ran.exitCode === 0) return parse(ran.stdout)
233    reason = `${python} ${SHIM} exited with status ${ran.exitCode}`
234  }
235  throw new Error(reason)
236}
237
238async function serverPort($: EngineInterface, cwd: string): Promise<number> {
239  const info: unknown = JSON.parse(await $.fs.read(`${cwd}/${RECORDS}/server.json`))
240  const port = isRecord(info) ? Number(info.port) : NaN
241  if (!Number.isInteger(port) || port <= 0 || port >= 65536) throw new Error('server.json names no port')
242  return port
243}
244
245async function serverToken($: EngineInterface, cwd: string): Promise<string> {
246  const token = (await $.fs.read(`${cwd}/${RECORDS}/http-token`)).trim()
247  if (!TOKEN.test(token)) throw new Error('the ledger token is missing or malformed')
248  return token
249}
250
251function within<T>($: EngineInterface, ms: number, work: Promise<T>): Promise<T> {
252  let timer: { cancel: () => void } | undefined
253  const late = new Promise<never>((_, reject) => {
254    timer = $.clock.after(ms, () => reject(new Error(`no answer within ${ms / 1000} s`)))
255  })
256  return Promise.race([work, late]).finally(() => timer?.cancel())
257}
258
259function pay($: EngineInterface, s: SentinelSwarmSession): Promise<void> {
260  const work = payOwed($, s).catch(() => undefined)
261  paying.add(work)
262  void work.finally(() => paying.delete(work))
263  return work
264}
265
266async function payOwed($: EngineInterface, s: SentinelSwarmSession): Promise<void> {
267  const answer = await post($, s, 'owed', base(s)).catch(() => null)
268  const listed = isRecord(answer) && Array.isArray(answer.wakeups) ? (answer.wakeups as Owed[]) : []
269  const owed = listed
270    .map(wakeup => ({ ...wakeup, wakeup_ids: wakeup.wakeup_ids.filter(id => !sending.has(id)) }))
271    .filter(wakeup => wakeup.wakeup_ids.length > 0)
272  if (owed.length === 0) return
273  const ids = owed.flatMap(wakeup => wakeup.wakeup_ids)
274  ids.forEach(id => sending.add(id))
275  try {
276    const delivered = (await Promise.all(owed.map(wakeup => deliver($, wakeup)))).flat()
277    if (delivered.length > 0) await post($, s, 'wake_sent', { ...base(s), wakeup_ids: delivered }).catch(() => null)
278  } finally {
279    ids.forEach(id => sending.delete(id))
280  }
281}
282
283function isOwnSend(e: { message?: unknown }): boolean {
284  return typeof e.message === 'string' && sendingTexts.has(e.message)
285}
286
287async function deliver($: EngineInterface, wakeup: Owed): Promise<number[]> {
288  sendingTexts.set(wakeup.text, (sendingTexts.get(wakeup.text) ?? 0) + 1)
289  try {
290    return await sendWithRetry($, wakeup)
291  } finally {
292    const left = (sendingTexts.get(wakeup.text) ?? 1) - 1
293    if (left > 0) sendingTexts.set(wakeup.text, left)
294    else sendingTexts.delete(wakeup.text)
295  }
296}
297
298async function sendWithRetry($: EngineInterface, wakeup: Owed): Promise<number[]> {
299  const to: SessionSendAddress = { sessionId: wakeup.session_id }
300  for (const pause of [0, ...SEND_RETRY_MS]) {
301    if (pause > 0) await $.clock.sleep(pause)
302    const sent = await within($, SEND_MS, $.session.send({ to, text: wakeup.text })).catch(() => undefined)
303    if (sent?.isDelivered) return wakeup.wakeup_ids
304    // A session registers 10 to 14 s after it starts; a send before that finds no live session.
305    if (sent === undefined || !NOT_YET_LIVE.test(sent.reason)) return []
306  }
307  return []
308}
309
310function withInbox(text: string, taken: Answer): string {
311  const messages = isRecord(taken) && Array.isArray(taken.messages) ? taken.messages : []
312  const remaining = isRecord(taken) && typeof taken.remaining === 'number' ? taken.remaining : 0
313  const rest = remaining > 0 ? ` ${remaining} more wait: call message_inbox for them.` : ''
314  return (
315    `${text}\n\nsentinel-swarm read your inbox with this wake-up and marked these messages read; ` +
316    `do not call message_inbox for them.${rest}\nmessage_inbox() returned: ${JSON.stringify({ messages, remaining })}`
317  )
318}
319
320function parse(text: string): Answer {
321  if (text.trim() === '') return null
322  const value: unknown = JSON.parse(text)
323  return isRecord(value) ? value : null
324}
325
326function hookOutput(answer: Answer): Record<string, unknown> | undefined {
327  const output = answer?.hookSpecificOutput
328  return isRecord(output) ? output : undefined
329}
330
331function field(answer: Answer, name: string, decision?: string): string | undefined {
332  const output = hookOutput(answer)
333  if (output === undefined || (decision !== undefined && output.permissionDecision !== decision)) return undefined
334  const value = output[name]
335  return typeof value === 'string' ? value : decision !== undefined ? 'denied by sentinel-swarm' : undefined
336}
337
338function cannotCheck(err: unknown): string {
339  return `sentinel-swarm cannot check this call: ${errorText(err)}; run /sentinel-swarm:setup`
340}
341
342function errorText(err: unknown): string {
343  if (err instanceof Error) return err.message
344  if (isRecord(err) && typeof err.message === 'string') return err.message
345  return String(err)
346}
347
348function isRecord(value: unknown): value is Record<string, unknown> {
349  return typeof value === 'object' && value !== null && !Array.isArray(value)
350}
351
types/index.d.ts 14 lines
1export type SentinelSwarmSession = {
2  sessionId: string
3  agentType: string
4  role: string
5  cwd: string
6  transcriptPath: string
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'sentinel-swarm': { session: SentinelSwarmSession | null }
12  }
13}
14