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

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.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.
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.
hooks/register.ts 121 lines1/**
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}
121types/index.d.ts 50 lines1/**
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