SLOPSHOPPER

legion-mod-runner

Companion to Legion Mod: starts, resumes and stops Legion's agents for it. Install both; it does nothing on its own.

newtimer
v0.2.0Apache-2.0updated 2026-10-08dnh33/legion-mod/plugins/legion-mod-runner
A shopper browsing a rack in a slop shop
README

Legion Mod for Claude Code

Legion's order of agents, inside Claude Code: thirteen named agents, threads, approvals and a shared Library, in your terminal or the Desktop Code tab.

This is the release mirror for Legion Mod. It holds two plugins and one marketplace:

  • plugins/legion-mod — the agents, approvals and stores. This is the plugin you install.
  • plugins/legion-mod-runner — a small companion that starts, resumes and stops the agents for legion-mod.

Install

claude plugin marketplace add dnh33/legion-mod claude plugin install legion-mod@legion

One install: legion-mod declares legion-mod-runner as a dependency, so Claude Code installs and enables both together.

Update with claude plugin update legion-mod@legion. Auto-update is off by default; turn it on under /plugin → Marketplaces → legion.

About

Developed in dnh33/legion under mod/ and mod-runner/. Report issues there. Licence: Apache-2.0. A mod runs with your permissions and is not sandboxed; the agents it starts are Claude Code subagents and use your own tools under your own permission rules.

Source 2 files
hooks/register.ts 121 lines
1/**
2 * legion-mod-runner: starts, resumes and stops Legion's agents on Legion Mod's behalf.
3 *
4 * Why a second plugin: Claude Code hides an agent's tool calls and model steps from the plugin whose hook caused the
5 * spawn (spike 2026-10-05, claude/plan-legion-mod.md §1b). Legion Mod must see every tool call to show threads, ask
6 * for approvals and track taint, so it never spawns itself. It queues requests in its own state (`runQueue`); this plugin
7 * reads that queue from its OWN timer, so nothing Legion Mod did is the cause of the spawn, and acts on each request once.
8 *
9 * It only acts on requests from Legion Mod's state (only Legion Mod can write that), and only for `legion-mod:` agent
10 * types. Without Legion Mod installed, the queue reads empty and the runner idles on a one-second timer.
11 */
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { RunRequest, RunResult } from '../types/index.d.ts'
15
16const RESULTS = { plugin: 'legion-mod-runner', key: 'results' } as const
17/** Polling period while requests are in flight, and while idle (milliseconds). A state read is an in-process call. */
18export const FAST_MS = 200
19export const IDLE_MS = 1000
20/** Results kept in state; Legion Mod clears answered requests from its queue long before this many pile up. */
21export const RESULTS_KEPT = 200
22/** A request older than this is not acted on: it belongs to an earlier session state, not to the person's current intent. */
23export const STALE_MS = 10 * 60 * 1000
24const AGENT_TYPE = /^legion-mod:[A-Za-z0-9_-]{1,64}$/
25
26/** Legion Mod's queue, read as plain data. Another plugin's key is outside this plugin's contract, hence the cast. */
27async function readQueue($: EngineInterface): Promise<RunRequest[]> {
28  const got = await $.state.get({ plugin: 'legion-mod', key: 'runQueue' } as any)
29  return Array.isArray(got?.value) ? (got.value as unknown as RunRequest[]) : []
30}
31
32async function readResults($: EngineInterface): Promise<RunResult[]> {
33  const got = await $.state.get(RESULTS)
34  return Array.isArray(got?.value) ? got.value : []
35}
36
37async function record($: EngineInterface, result: RunResult): Promise<void> {
38  const got = await $.state.get(RESULTS)
39  const list: RunResult[] = Array.isArray(got?.value) ? got.value : []
40  await $.state.set(RESULTS, [...list, result].slice(-RESULTS_KEPT))
41}
42
43/** Writes the results list once at start (empty when nothing ran yet), so Legion Mod's doctor can see the runner is here. */
44async function announce($: EngineInterface): Promise<void> {
45  const got = await $.state.get(RESULTS)
46  if (got.version === 0) await $.state.set(RESULTS, [])
47}
48
49const message = (err: unknown): string => (err instanceof Error ? err.message : String(err)).slice(0, 300)
50
51/** Carries out one request. Never throws: a failure comes back as `ok: false` with a plain sentence. */
52async function perform($: EngineInterface, req: RunRequest, now: number): Promise<RunResult> {
53  const base = { requestId: req.id, kind: req.kind, taskId: req.taskId, at: now }
54  try {
55    if (req.kind === 'spawn') {
56      if (!req.agentType || !AGENT_TYPE.test(req.agentType)) return { ...base, ok: false, error: `Not a Legion agent type: ${String(req.agentType)}` }
57      if (!req.prompt) return { ...base, ok: false, error: 'Nothing to send: the request has no prompt.' }
58      const spawned = await $.agent.spawn({
59        prompt: req.prompt,
60        subagentType: req.agentType,
61        ...(req.name ? { name: req.name } : {}),
62        ...(req.model ? { model: req.model } : {}),
63        ...(req.description ? { description: req.description } : {}),
64      })
65      if ('deny' in spawned) return { ...base, ok: false, error: `Claude Code refused to start the agent: ${String(spawned.deny)}` }
66      if (!spawned.agentId) return { ...base, ok: false, error: 'Claude Code started no agent for this request.' }
67      return { ...base, ok: true, runId: spawned.agentId, model: spawned.model }
68    }
69    if (!req.runId) return { ...base, ok: false, error: 'No run to act on: the request has no run id.' }
70    if (req.kind === 'resume') {
71      if (!req.prompt) return { ...base, ok: false, error: 'Nothing to send: the request has no prompt.' }
72      const sent = await $.session.send({ to: { agentId: req.runId }, text: req.prompt })
73      return sent.isDelivered ? { ...base, ok: true, runId: req.runId } : { ...base, ok: false, runId: req.runId, error: `The agent could not be reached: ${String(sent.reason ?? 'no reason given')}` }
74    }
75    const stopped = await $.tool.call({ tool: 'TaskStop', task_id: req.runId })
76    if (stopped && 'deny' in stopped && stopped.deny) return { ...base, ok: false, runId: req.runId, error: `Claude Code refused to stop it: ${String(stopped.deny)}` }
77    const failed = stopped as { isError?: boolean; text?: string } | undefined
78    if (!failed?.isError) return { ...base, ok: true, runId: req.runId }
79    // A run that is gone is the stop's goal reached; any other error is a real failure, said with the engine's own words.
80    const said = String(failed.text ?? '').slice(0, 200)
81    const gone = /not (found|running)|no (such )?(task|agent)|already (stopped|finished|completed)|has (finished|completed)/i.test(said)
82    return { ...base, ok: false, runId: req.runId, error: gone ? 'The agent had already stopped.' : `Claude Code could not stop it: ${said || 'no reason given'}` }
83  } catch (err) {
84    return { ...base, ok: false, error: message(err) }
85  }
86}
87
88/** One pass: act on every request not answered yet, then schedule the next pass. */
89async function tick($: EngineInterface): Promise<void> {
90  let delay = IDLE_MS
91  try {
92    const queue = await readQueue($)
93    if (queue.length > 0) {
94      delay = FAST_MS
95      const answered = new Set((await readResults($)).map(r => r.requestId))
96      const now = await $.clock.now()
97      for (const req of queue) {
98        if (answered.has(req.id)) continue
99        if (now - req.at > STALE_MS) {
100          // Too old to act on, but answered, so Legion Mod stops waiting and drops it from its queue.
101          await record($, { requestId: req.id, kind: req.kind, taskId: req.taskId, ok: false, error: 'This request waited more than ten minutes, so it was not carried out. Send it again.', at: now })
102          continue
103        }
104        await record($, await perform($, req, now))
105      }
106    }
107  } catch (err) {
108    $.ui.log(`legion-mod-runner: ${message(err)}`, { to: 'debug' })
109  }
110  $.clock.after(delay, () => void tick($))
111}
112
113export const register: Register = on => {
114  on('session.start', async ($, e, next) => {
115    const started = await next(e)
116    await announce($)
117    $.clock.after(IDLE_MS, () => void tick($))
118    return started
119  })
120}
121
types/index.d.ts 50 lines
1/**
2 * legion-mod-runner's contract. RunRequest and RunResult are declared identically in mod/types/index.d.ts; a spec in
3 * mod/test/node keeps the two copies equal, because the plugins read each other's state and must agree on the shape.
4 */
5
6/** A lifecycle request Legion Mod queues for the runner (legion-mod's `runQueue`). */
7export type RunRequest = {
8  /** Request id (`rq_` + 12 hex). The runner answers each id once. */
9  id: string
10  kind: 'spawn' | 'resume' | 'stop'
11  /** Legion's task id the request belongs to. */
12  taskId: string
13  /** spawn: the agent type, always `legion-mod:<agent id>`. */
14  agentType?: string
15  /** spawn: the name the run is addressable by. */
16  name?: string
17  /** spawn and resume: the text the agent receives. */
18  prompt?: string
19  /** spawn: an alias (`sonnet`, `opus`, `haiku`) or a model id. Absent: the agent type's own model. */
20  model?: string
21  /** spawn: the one-line description shown in Claude Code's agent list. */
22  description?: string
23  /** resume and stop: the Claude Code agent id of the run. */
24  runId?: string
25  at: number
26}
27
28/** What the runner did with one request (legion-mod-runner's `results`). */
29export type RunResult = {
30  requestId: string
31  kind: RunRequest['kind']
32  taskId: string
33  ok: boolean
34  /** spawn: the new run's agent id. resume and stop: the run acted on. */
35  runId?: string
36  model?: string
37  /** A plain sentence when ok is false. */
38  error?: string
39  at: number
40}
41
42declare module 'claude-code' {
43  interface PluginState {
44    'legion-mod-runner': {
45      /** The last results, newest last, at most RESULTS_KEPT. */
46      results: RunResult[]
47    }
48  }
49}
50