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

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.tsx 13 lines1/**
2 * Legion mod: the wiring. The runtime (src/wire/legion.tsx) observes and acts; the UI (src/ui/register-ui.tsx) draws.
3 */
4import type { Register } from 'claude-code'
5
6import { registerUi } from '../src/ui/register-ui.tsx'
7import { registerLegion } from '../src/wire/legion.tsx'
8
9export const register: Register = on => {
10 registerLegion(on)
11 registerUi(on)
12}
13src/ui/register-ui.tsx 134 lines1/**
2 * The Legion UI's hooks: the pane, the band above the prompt, the `/to` and `/say` transcript cards, and the status
3 * line. `hooks/register.tsx` calls `registerUi(on)` once; everything else in src/ui is pure.
4 *
5 * Each render hook reads the state it draws from (`$.state`, which subscribes the drawing, so a write redraws it),
6 * builds rows in pure code, and turns them into the surface's elements. Nothing here writes state, and no Button acts
7 * by itself: each carries its action in its key (actions.ts), which the lead's `ui.press` hook answers.
8 */
9import { atom, read } from 'claude-code'
10import type { EngineInterface, On, RenderElement } from 'claude-code'
11import type { ThreadRow } from '../../types/index.d.ts'
12import { palette } from '../theme.ts'
13import { bandRows } from './band.ts'
14import { Pane, Rows } from './components.tsx'
15import { dispatchRows, taskIdIn } from './dispatch.ts'
16import { statusText } from './status.ts'
17import { DEFAULT_UI, selectedTask, type Snapshot } from './views/common.ts'
18import { paneLayout } from './views/pane.ts'
19
20const agents = atom({ plugin: 'legion-mod', key: 'agents' } as const, [])
21const tasks = atom({ plugin: 'legion-mod', key: 'tasks' } as const, [])
22const cards = atom({ plugin: 'legion-mod', key: 'cards' } as const, [])
23const ui = atom({ plugin: 'legion-mod', key: 'ui' } as const, DEFAULT_UI)
24const moods = atom({ plugin: 'legion-mod', key: 'moods' } as const, {})
25const band = atom({ plugin: 'legion-mod', key: 'band' } as const, [])
26const theme = atom({ plugin: 'legion-mod', key: 'theme' } as const, 'dark')
27const sessionId = atom({ plugin: 'legion-mod', key: 'sessionId' } as const, '')
28const doctor = atom({ plugin: 'legion-mod', key: 'doctor' } as const, [])
29const THREADS = { plugin: 'legion-mod', key: 'threads' } as const
30const LIVE = { plugin: 'legion-mod', key: 'live' } as const
31
32/**
33 * Keys whose change can change the status line. `threads` is one write per row (a tool starting or ending), and the
34 * line's verb comes from it: without it the verb froze until the mood changed. `live` changes on every streamed chunk
35 * and never changes the line.
36 */
37const STATUS_KEYS: ReadonlySet<string> = new Set(['agents', 'tasks', 'cards', 'ui', 'moods', 'threads'])
38
39/**
40 * One read of the state a drawing needs. `thread` and `live` are read for one task: the selected task's for the pane,
41 * the dispatched task's for a transcript card, none for the band and the status line.
42 */
43async function readSnapshot($: EngineInterface, taskFor: 'selected' | 'none' | string): Promise<Snapshot> {
44 const s: Snapshot = {
45 agents: await read($, agents),
46 tasks: await read($, tasks),
47 cards: await read($, cards),
48 ui: { ...DEFAULT_UI, ...(await read($, ui)) },
49 moods: await read($, moods),
50 band: await read($, band),
51 thread: [],
52 live: '',
53 now: await $.clock.now(),
54 sessionId: await read($, sessionId),
55 }
56 if (taskFor === 'selected') s.doctor = await read($, doctor)
57 const id = taskFor === 'selected' ? selectedTask(s)?.id : taskFor === 'none' ? undefined : taskFor
58 if (id) {
59 const thread: ThreadRow[] | undefined = await read($, { ...THREADS, id })
60 s.thread = thread ?? []
61 s.live = (await read($, { ...LIVE, id })) ?? ''
62 s.threads = { [id]: s.thread }
63 }
64 // the working tasks' own rows, for what each is doing now (R2): the pane's tree and Order, or the status line's agent.
65 // Capped, newest first; each read subscribes the drawing, so a tool starting redraws its verb.
66 const workers = s.tasks.filter(t => t.status === 'running' && t.id !== id)
67 .filter(t => taskFor !== 'none' || t.agentId === s.ui.agentId)
68 .sort((a, b) => b.updatedAt - a.updatedAt).slice(0, THREADS_READ_MAX)
69 if (workers.length > 0) {
70 const threads: Record<string, readonly ThreadRow[]> = { ...s.threads }
71 for (const t of workers) threads[t.id] = (await read($, { ...THREADS, id: t.id })) ?? []
72 s.threads = threads
73 }
74 return s
75}
76
77/** The snapshot with one task's just-written rows laid over it (a `threads` write names its task by the family id). */
78const withThread = (s: Snapshot, e: { id?: string; value: unknown }): Snapshot =>
79 e.id ? { ...s, threads: { ...s.threads, [e.id]: e.value as readonly ThreadRow[] } } : s
80
81/** At most this many other tasks' rows are read per drawing; past it a task's verb reads "thinking". */
82const THREADS_READ_MAX = 12
83
84/** The card a `/to` or `/say` row leaves, or undefined to let the engine draw the row's text. */
85async function dispatchTree($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0] & { props: { text: string; isErrored: boolean }; viewport?: { columns: number } }): Promise<RenderElement | undefined> {
86 const id = taskIdIn(e.props.text)
87 if (!id || e.props.isErrored) return undefined
88 const s = await readSnapshot($, id)
89 // CommandOutput has no bodyColumns: the transcript's width is the viewport's, else 80
90 const columns = e.viewport?.columns && e.viewport.columns > 0 ? Math.floor(e.viewport.columns) : 80
91 const rows = dispatchRows(s, e.props.text, columns)
92 if (!rows) return undefined
93 return Rows($.ui.resolve(e), palette(await read($, theme)), rows)
94}
95
96export function registerUi(on: On): void {
97 /**
98 * The status line follows every write to a key it shows; the first write (the engine seeding the agents at session
99 * start) pins it. It changes only when its text does: a module value, reset harmlessly on reload.
100 */
101 let lastStatus: string | undefined
102
103 on('ui.render', { component: 'Pane', requestId: 'legion' }, async ($, e) => {
104 const s = await readSnapshot($, 'selected')
105 const pal = palette(await read($, theme))
106 return Pane($.ui.resolve(e), pal, paneLayout(s, e.props.bodyColumns, e.props.scroll.bodyRows))
107 })
108
109 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110 if (e.props.hasSurvey) return next(e)
111 const rows = bandRows(await readSnapshot($, 'none'), e.props.bodyColumns, e.props.maxRows)
112 if (rows.length === 0) return next(e)
113 return Rows($.ui.resolve(e), palette(await read($, theme)), rows)
114 })
115
116 on('ui.render', { component: 'CommandOutput', props: { command: 'to' } }, async ($, e, next) => (await dispatchTree($, e)) ?? next(e))
117
118 on('ui.render', { component: 'CommandOutput', props: { command: 'say' } }, async ($, e, next) => (await dispatchTree($, e)) ?? next(e))
119
120 on('state.set', { plugin: 'legion-mod' }, async ($, e, next) => {
121 const done = await next(e)
122 if (!STATUS_KEYS.has(e.key)) return done
123 // every get of one dispatch reads one moment, which may be before this write lands: lay the written value over it
124 const s = await readSnapshot($, 'none')
125 const landed = done.deny === undefined && done.value.isSet
126 const text = statusText(!landed ? s : e.key === 'threads' ? withThread(s, e) : { ...s, [e.key]: e.value })
127 if (text !== lastStatus) {
128 lastStatus = text
129 $.ui.status(text)
130 }
131 return done
132 })
133}
134src/wire/legion.tsx 1159 lines1/**
2 * The runtime: every hook that needs `$`, in one file (the validator lets `$` reach only functions declared in the same
3 * file). It reads events, asks the pure modules what they mean (src/wire/core.ts, src/engine/*), and writes the result to
4 * the stores (durable), to `$.state` (what the UI draws) and to legion-mod-runner's queue (agent lifecycle).
5 *
6 * Two rules shape it (plan §1b):
7 * - This plugin never spawns, resumes or stops an agent itself: Claude Code hides an agent's tool calls from the plugin
8 * whose hook caused it. Requests go into `runQueue`; legion-mod-runner acts on them from its own timer.
9 * - Hooks observe every run (turn.step, tool.call, session.append, turn.complete) and act only on runs that belong to a
10 * Legion task (`ctx.byRun`), or adopt a run of a `legion-mod:` agent type that Claude Code's model or another agent started.
11 */
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { AgentView, ApprovalCard, BandItem, DoctorLine, ModSettings, Mood, RunRequest, RunResult, TaskOrigin, TaskView, ThreadRow, ViewId } from '../../types/index.d.ts'
15import { answerTaintsCaller, checkAsk, parseLegionAgentType } from '../engine/bridge.ts'
16import { CONTINUE_PROMPT, inferTurnLimit } from '../engine/continue.ts'
17import { PRICES_AS_OF, tokensFromUsage } from '../engine/cost.ts'
18import { nextMood } from '../engine/mood.ts'
19import { buildAgentSpec } from '../engine/prompt.ts'
20import { seedAgents } from '../engine/roster.ts'
21import { pickModel } from '../engine/router.ts'
22import { moodFor, reduceTask, reduceThread, type RunEvent } from '../engine/runs.ts'
23import { isLegionModTool } from '../engine/tool-names.ts'
24import { needsApproval, stricterMode, summarizeToolInput } from '../engine/approvals.ts'
25import { taintsRun } from '../engine/taint.ts'
26import { agentsList } from '../engine/bridge.ts'
27import { dataRoot, fsPort } from '../store/fs-port.ts'
28import { newId } from '../store/ids.ts'
29import { createAgentStore, createSettingsStore, createTaskStore, createThreadStore, DEFAULT_SETTINGS, type AgentStore, type SettingsStore, type TaskStore, type ThreadStore } from '../store/stores.ts'
30import { MOOD_DWELL_MS, TRANSIENT_MOOD_MS, palette, themeFromClaude } from '../theme.ts'
31import { decodeArt, type Art } from '../art/art.ts'
32import { createStage, invalidateStage, musterCells, stepStage, type StageRuntime } from '../art/driver.ts'
33import { bandItemAt, decodeAction, type DecodedAction } from '../ui/actions.ts'
34import { FLUSH_MS, newTrace, record, traceFile, traceText, type TraceLine } from './trace.ts'
35import { assistantText, finishedLine, latestTaskOf, orderOnlyMessage, toolInput, toolLine, makeTask, newCtx, notificationRunId, parseTo, pushBand, resolveAgent, resumeRequest, spawnRequest, stopRequest, taskList, type Ctx } from './core.ts'
36
37// ---- State the UI draws (one reference each; literals, as the validator requires) ----
38const AGENTS = { plugin: 'legion-mod', key: 'agents' } as const
39const TASKS = { plugin: 'legion-mod', key: 'tasks' } as const
40const THREADS = { plugin: 'legion-mod', key: 'threads' } as const
41const LIVE = { plugin: 'legion-mod', key: 'live' } as const
42const CARDS = { plugin: 'legion-mod', key: 'cards' } as const
43const UI = { plugin: 'legion-mod', key: 'ui' } as const
44const MOODS = { plugin: 'legion-mod', key: 'moods' } as const
45const BAND = { plugin: 'legion-mod', key: 'band' } as const
46const SETTINGS = { plugin: 'legion-mod', key: 'settings' } as const
47const THEME = { plugin: 'legion-mod', key: 'theme' } as const
48const QUEUE = { plugin: 'legion-mod', key: 'runQueue' } as const
49const SESSION = { plugin: 'legion-mod', key: 'sessionId' } as const
50const DOCTOR = { plugin: 'legion-mod', key: 'doctor' } as const
51const STAGE = { plugin: 'legion-mod', key: 'stage' } as const
52const MUSTER = { plugin: 'legion-mod', key: 'muster' } as const
53/** legion-mod-runner's state is outside this plugin's contract (validate lists it under "state of other plugins"), hence the casts. */
54const RESULTS = { plugin: 'legion-mod-runner', key: 'results' } as const
55/** The same value as a hook matcher. The validator refuses one const used both as a state reference and as a matcher. */
56const RESULTS_MATCH = { plugin: 'legion-mod-runner', key: 'results' } as const
57
58/** How often other windows' changes are picked up (tasks, settings, agents), and the heartbeat with them. */
59const REFRESH_MS = 2000
60/** A live reply is published at most this often (the shown pane redraws at up to 30/s; 8/s reads as live and costs little). */
61const LIVE_MS = 125
62
63type Stores = { tasks: TaskStore; threads: ThreadStore; settings: SettingsStore; agents: AgentStore }
64
65let ctx: Ctx = newCtx({ ...DEFAULT_SETTINGS })
66let stores: Stores | undefined
67let dataRootPath = ''
68let trace = newTrace(false)
69let traceFlushPending = false
70let liveAt = 0
71
72const message = (err: unknown): string => (err instanceof Error ? err.message : String(err)).slice(0, 300)
73
74// ---- Development trace (src/wire/trace.ts): off by default; one boolean check when off ----
75
76async function tr($: EngineInterface, line: Omit<TraceLine, 't'>): Promise<void> {
77 if (!trace.isOn) return
78 record(trace, { t: await $.clock.now(), ...line } as TraceLine)
79 if (traceFlushPending) return
80 traceFlushPending = true
81 $.clock.after(FLUSH_MS, () => void flushTrace($))
82}
83
84async function flushTrace($: EngineInterface): Promise<void> {
85 traceFlushPending = false
86 if (!trace.isDirty || !dataRootPath || !ctx.sessionId) return
87 trace.isDirty = false
88 try {
89 await $.fs.write(`${dataRootPath}/${traceFile(ctx.sessionId)}`, traceText(trace))
90 } catch (err) {
91 $.ui.log(`legion-mod: trace not written: ${message(err)}`, { to: 'debug' })
92 }
93}
94
95// ---- Publishing ------------------------------------------------------------------------------------------------------
96
97async function publishTasks($: EngineInterface): Promise<void> {
98 await $.state.set(TASKS, taskList(ctx))
99}
100
101async function publishThread($: EngineInterface, taskId: string): Promise<void> {
102 await $.state.set({ ...THREADS, id: taskId }, ctx.threads.get(taskId) ?? [])
103}
104
105async function publishQueue($: EngineInterface): Promise<void> {
106 await $.state.set(QUEUE, [...ctx.pending.values()])
107}
108
109async function say($: EngineInterface, text: string): Promise<void> {
110 $.ui.toast(text)
111}
112
113// ---- Events through the pure reducers ----------------------------------------------------------------------------------
114
115/** Applies one run event to its task and thread, persists what changed and publishes it. */
116async function apply($: EngineInterface, taskId: string, ev: RunEvent, opts: { persistRows?: boolean } = { persistRows: true }): Promise<TaskView | undefined> {
117 const at = (await $.clock.now())
118 // After a hot reload the thread is not in memory yet: load it first, so new rows follow the stored ones.
119 if (!ctx.threads.has(taskId) && stores && ev.type !== 'queued') ctx.threads.set(taskId, await stores.threads.load(taskId))
120 const before = ctx.tasks.get(taskId)
121 const after = reduceTask(before, ev, at, ctx.settings)
122 if (after && after !== before) {
123 ctx.tasks.set(taskId, after)
124 if (!before || before.status !== after.status || ev.type === 'finished' || ev.type === 'started' || ev.type === 'queued') await stores?.tasks.put(after)
125 await publishTasks($)
126 }
127 const rows = ctx.threads.get(taskId) ?? []
128 const nextRows = reduceThread(rows, ev, at, ctx.settings)
129 if (nextRows !== rows) {
130 ctx.threads.set(taskId, nextRows)
131 if (opts.persistRows) {
132 const changed = nextRows.filter(r => !rows.includes(r))
133 if (changed.length) await stores?.threads.append(taskId, changed)
134 }
135 await publishThread($, taskId)
136 }
137 const wanted = moodFor(ev)
138 const agentId = (after ?? before)?.agentId
139 if (agentId) lastEventAt.set(agentId, at)
140 if (wanted && agentId) await setMood($, agentId, wanted)
141 return after
142}
143
144/** The latest mood each agent's events asked for, and whether a deferred check is already waiting (one per agent). */
145const wantedMood = new Map<string, Mood>()
146const moodCheckPending = new Set<string>()
147
148/** Shows `wanted` now, or once the current mood has had its minimum time; the latest wanted mood always wins (mood.ts). */
149async function setMood($: EngineInterface, agentId: string, wanted: Mood): Promise<void> {
150 wantedMood.set(agentId, wanted)
151 const seq = (moodSeq.get(agentId) ?? 0) + 1
152 moodSeq.set(agentId, seq)
153 await settleMood($, agentId)
154 // Victory and Fault are moments, not states: the agent rests once they have shown, unless something newer was asked for.
155 const lasts = TRANSIENT_MOOD_MS[wanted]
156 if (lasts !== undefined) {
157 $.clock.after(MOOD_DWELL_MS + lasts, () => {
158 if (moodSeq.get(agentId) === seq) void setMood($, agentId, 'idle')
159 })
160 }
161}
162
163/** Counts each agent's mood requests, so a transient mood's rest only lands when nothing newer came after it. */
164const moodSeq = new Map<string, number>()
165
166async function settleMood($: EngineInterface, agentId: string): Promise<void> {
167 const wanted = wantedMood.get(agentId)
168 if (!wanted) return
169 const at = await $.clock.now()
170 const step = nextMood(ctx.moods[agentId], wanted, at)
171 if (step.state !== ctx.moods[agentId]) {
172 ctx.moods = { ...ctx.moods, [agentId]: step.state }
173 await $.state.set(MOODS, ctx.moods)
174 await tr($, { k: 'mood', agent: agentId, mood: step.state.mood })
175 if (ctx.settings.twoD) { await driveStage($); await publishMuster($) }
176 }
177 if (step.recheckAt !== undefined && !moodCheckPending.has(agentId)) {
178 moodCheckPending.add(agentId)
179 $.clock.after(Math.max(0, step.recheckAt - at), () => {
180 moodCheckPending.delete(agentId)
181 void settleMood($, agentId)
182 })
183 }
184}
185
186/**
187 * A Legion task's tool call that waits for the person's answer in Claude Code's permission dialog. The pane and the band show it
188 * as "needs your OK" until the call resolves (live run 2026-10-05: without this the pane said "running" while Scout waited).
189 */
190async function openCard($: EngineInterface, task: TaskView, toolUseId: string, tool: string, input: Record<string, unknown>): Promise<void> {
191 if (ctx.cards.some(c => c.id === toolUseId)) return
192 const parent = task.origin.kind === 'bridge' ? ctx.tasks.get(task.origin.fromTaskId) : undefined
193 const from = parent ? ctx.agents.find(a => a.id === parent.agentId) : undefined
194 const card: ApprovalCard = {
195 id: toolUseId, taskId: task.id, agentId: task.agentId, tool, summary: summarizeToolInput(tool, input),
196 ...(from ? { origin: `Handed over by ${from.glyph} ${from.name}` } : {}),
197 isClickOnly: false, at: await $.clock.now(),
198 }
199 ctx.cards = [...ctx.cards, card]
200 await $.state.set(CARDS, ctx.cards)
201 await apply($, task.id, { type: 'card', card }, { persistRows: false })
202 await addBand($, { id: `card-${toolUseId}`, kind: 'card', taskId: task.id, agentId: task.agentId, text: `${task.title} · needs your OK`, at: card.at })
203 await tr($, { k: 'tool', task: task.id, agent: task.agentId, tool, id: toolUseId, summary: 'needs your OK' })
204}
205
206async function closeCard($: EngineInterface, taskId: string, toolUseId: string, allowed: boolean): Promise<void> {
207 ctx.cards = ctx.cards.filter(c => c.id !== toolUseId)
208 await $.state.set(CARDS, ctx.cards)
209 ctx.band = ctx.band.filter(b => b.id !== `card-${toolUseId}`)
210 await $.state.set(BAND, ctx.band)
211 await apply($, taskId, { type: 'cardDone', toolUseId, allowed }, { persistRows: false })
212}
213
214async function addBand($: EngineInterface, item: BandItem): Promise<void> {
215 ctx.band = pushBand(ctx.band, item)
216 await $.state.set(BAND, ctx.band)
217}
218
219// ---- Lifecycle requests (legion-mod-runner acts on them) -----------------------------------------------------------------
220
221async function enqueue($: EngineInterface, req: RunRequest): Promise<void> {
222 await tr($, { k: 'queue', task: req.taskId, run: req.runId, kind: req.kind, agentType: req.agentType, model: req.model, prompt: req.prompt })
223 ctx.pending.set(req.id, req)
224 await publishQueue($)
225}
226
227/** Starts a new task for `agent` with `text`. Returns the task. */
228async function startTask($: EngineInterface, agent: AgentView, text: string, origin: TaskOrigin = { kind: 'person' }): Promise<TaskView> {
229 // The router reads and strips a leading /opus, /sonnet or /model; the title and the thread show the message without it.
230 const pick = pickModel({ agentModel: agent.model, prompt: text, fromBot: origin.kind !== 'person' })
231 const task = makeTask({ id: newId('t'), agentId: agent.id, text: pick.prompt, sessionId: ctx.sessionId, origin, now: (await $.clock.now()) })
232 ctx.tasks.set(task.id, task)
233 await apply($, task.id, { type: 'queued', task, prompt: pick.prompt })
234 await enqueue($, spawnRequest({ id: newId('rq'), task, agent, prompt: pick.prompt, model: pick.model, now: (await $.clock.now()) }))
235 ctx.ui = { ...ctx.ui, agentId: agent.id, taskId: task.id }
236 await $.state.set(UI, ctx.ui)
237 return task
238}
239
240/** Sends `text` into an existing task: resumes its stopped run, or starts it again when it never ran. */
241async function continueTask($: EngineInterface, task: TaskView, text: string = CONTINUE_PROMPT): Promise<string> {
242 if (task.sessionId !== ctx.sessionId) return `${task.title} is working in another window. Continue it there.`
243 if (task.status === 'running' || task.status === 'queued') return `${task.title} is still working. Wait for it, or stop it first.`
244 if (!task.runId) return `${task.title} has not started yet. It starts on its own.`
245 await enqueue($, resumeRequest({ id: newId('rq'), task, text, now: (await $.clock.now()) }))
246 return ''
247}
248
249async function stopTask($: EngineInterface, task: TaskView): Promise<string> {
250 if (task.sessionId !== ctx.sessionId) return `${task.title} is working in another window. Stop it there.`
251 if (task.status !== 'running' && task.status !== 'queued') return `${task.title} is not working.`
252 if (!task.runId) {
253 // Not started yet: take the spawn out of the queue. The runner may already be starting it; remember the request, so a
254 // run that arrives anyway is stopped at once (onResult, adopt) instead of running on unseen.
255 for (const [id, req] of ctx.pending) if (req.taskId === task.id && req.kind === 'spawn') { ctx.pending.delete(id); cancelledSpawns.set(id, req) }
256 cancelledTasks.add(task.id)
257 await publishQueue($)
258 } else {
259 await enqueue($, stopRequest({ id: newId('rq'), task, now: (await $.clock.now()) }))
260 }
261 await apply($, task.id, { type: 'stopped', taskId: task.id })
262 return ''
263}
264
265/** Spawn requests withdrawn by a stop, and tasks stopped before their run started: a run that arrives for them is stopped. */
266const cancelledSpawns = new Map<string, RunRequest>()
267const cancelledTasks = new Set<string>()
268
269/** Stops a run that started for a task the person had already stopped. */
270async function stopLateRun($: EngineInterface, taskId: string, runId: string): Promise<void> {
271 const task = ctx.tasks.get(taskId)
272 if (!task) return
273 ctx.byRun.set(runId, taskId)
274 await tr($, { k: 'deny', task: taskId, run: runId, tool: 'spawn', reason: 'stopped before it started: stopping the run that arrived' })
275 await enqueue($, stopRequest({ id: newId('rq'), task: { ...task, runId }, now: await $.clock.now() }))
276}
277
278/** One runner result: link a new run to its task, or say what failed. Applied once per request. */
279async function onResult($: EngineInterface, r: RunResult): Promise<void> {
280 if (ctx.applied.has(r.requestId)) return
281 if (cancelledSpawns.has(r.requestId)) {
282 ctx.applied.add(r.requestId)
283 cancelledSpawns.delete(r.requestId)
284 if (r.ok && r.runId) await stopLateRun($, r.taskId, r.runId)
285 return
286 }
287 const req = ctx.pending.get(r.requestId)
288 if (!req) return
289 ctx.applied.add(r.requestId)
290 ctx.pending.delete(r.requestId)
291 await tr($, { k: 'result', task: req.taskId, run: r.runId, kind: req.kind, ok: r.ok, error: r.error, model: r.model })
292 await publishQueue($)
293 const task = ctx.tasks.get(req.taskId)
294 if (!task) return
295 if (!r.ok) {
296 const at = await $.clock.now()
297 if (req.kind === 'stop') {
298 if (/already stopped/i.test(r.error ?? '')) return // it had stopped on its own: cancelled either way
299 if (/waited more than ten minutes/i.test(r.error ?? '')) {
300 // The runner never acted on the stop; whether the run is still going is unknown. Say exactly that.
301 await addBand($, { id: newId('b'), kind: 'error', taskId: task.id, agentId: task.agentId, text: `The stop for ${task.title} was never carried out: legion-mod-runner did not answer for ten minutes. Check /legion doctor; the run may still be going.`, at })
302 return
303 }
304 // The stop did not land: the run is still going. Say so, and show it as running again.
305 const back = { ...task, status: 'running' as const, updatedAt: at }
306 ctx.tasks.set(task.id, back)
307 await stores?.tasks.put(back)
308 await publishTasks($)
309 await addBand($, { id: newId('b'), kind: 'error', taskId: task.id, agentId: task.agentId, text: `Could not stop ${task.title}: ${r.error ?? 'no reason given'}. It is still working.`, at })
310 return
311 }
312 // A start or resume that failed: the task says why, and the thread shows it, whether or not a run ever existed.
313 const failed = { ...task, status: 'error' as const, error: r.error ?? 'Legion could not reach the agent.', updatedAt: at }
314 ctx.tasks.set(task.id, failed)
315 await stores?.tasks.put(failed)
316 await publishTasks($)
317 if (!ctx.threads.has(task.id) && stores) ctx.threads.set(task.id, await stores.threads.load(task.id))
318 const row: ThreadRow = { id: `${task.id}:fail:${r.requestId}`, role: 'system', text: `Failed: ${failed.error}`, at }
319 ctx.threads.set(task.id, [...(ctx.threads.get(task.id) ?? []), row])
320 await stores?.threads.append(task.id, [row])
321 await publishThread($, task.id)
322 await addBand($, { id: newId('b'), kind: 'error', taskId: task.id, agentId: task.agentId, text: r.error ?? 'The agent could not start.', at })
323 return
324 }
325 if (req.kind === 'spawn' && r.runId) {
326 // The run may have been linked already, by name, when its first step beat this result (adopt): do not restart its count.
327 if (ctx.byRun.get(r.runId) === task.id) return
328 ctx.byRun.set(r.runId, task.id)
329 await apply($, task.id, { type: 'started', taskId: task.id, runId: r.runId, model: r.model ?? req.model ?? '' })
330 } else if (req.kind === 'resume' && r.runId) {
331 ctx.byRun.set(r.runId, task.id)
332 await apply($, task.id, { type: 'continued', taskId: task.id, runId: r.runId })
333 }
334}
335
336// ---- Adoption: runs of Legion agents that this plugin did not queue ----------------------------------------------------
337
338/** A run of a `legion-mod:` agent type that Claude Code's model or another agent started: make it a Legion task. */
339async function adopt($: EngineInterface, runId: string): Promise<string | undefined> {
340 const known = ctx.byRun.get(runId)
341 if (known) return known
342 if (!ctx.isReady) return undefined // boot is still loading the tasks: the next event links it, against the full list
343 const info = (await $.agent.list()).find(a => a.id === runId)
344 const agentId = info ? parseLegionAgentType(info.type) : null
345 const agent = agentId ? ctx.agents.find(a => a.id === agentId) : undefined
346 if (!info || !agent) return undefined
347 // A run Legion queued carries its task in its name (`<agent>-t_<12 hex>`, core.ts spawnRequest). Its first step can beat the
348 // runner's result: link it to that task instead of adopting a twin.
349 // Only a run the runner started, for a spawn Legion queued under exactly that name: a model can pick any name with
350 // Agent({ name }), so a name alone must never claim a task (G3 re-review R1: it would drop the bridge's approval ceiling).
351 const openSpawn = info.spawnedBy === 'legion-mod-runner' && info.name
352 ? [...ctx.pending.values(), ...cancelledSpawns.values()].find(r => r.kind === 'spawn' && r.name === info.name)
353 : undefined
354 const queued = openSpawn ? ctx.tasks.get(openSpawn.taskId) : undefined
355 if (queued && openSpawn) {
356 if (cancelledSpawns.has(openSpawn.id) || cancelledTasks.has(queued.id) || queued.status === 'cancelled') { await stopLateRun($, queued.id, runId); return queued.id }
357 ctx.byRun.set(runId, queued.id)
358 await tr($, { k: 'adopt', task: queued.id, agent: agent.id, run: runId, origin: 'queued', via: 'name' })
359 await apply($, queued.id, { type: 'started', taskId: queued.id, runId, model: '' })
360 return queued.id
361 }
362 const parentTask = info.parentId ? ctx.byRun.get(info.parentId) : undefined
363 const parent = parentTask ? ctx.tasks.get(parentTask) : undefined
364 const origin: TaskOrigin = parent
365 ? { kind: 'bridge', fromAgentId: parent.agentId, fromTaskId: parent.id, hop: (parent.origin.kind === 'person' || parent.origin.kind === 'claude-code' ? 0 : parent.origin.hop) + 1, depth: (parent.origin.kind === 'bridge' ? parent.origin.depth : 0) + 1 }
366 : { kind: 'claude-code' }
367 // The run's opening message is the request: it titles the task and opens its thread, as a /to message would.
368 const read = await $.session.messages({ agentId: runId })
369 const opening = Array.isArray(read) ? (read.find(m => m.role === 'user')?.text?.trim() ?? '') : ''
370 const task = makeTask({ id: newId('t'), agentId: agent.id, text: opening || info.description || agent.name, sessionId: ctx.sessionId, origin, now: (await $.clock.now()) })
371 ctx.tasks.set(task.id, task)
372 ctx.byRun.set(runId, task.id)
373 await tr($, { k: 'adopt', task: task.id, agent: agent.id, run: runId, origin: origin.kind, parentTask: parent?.id, title: task.title })
374 await apply($, task.id, { type: 'queued', task, ...(opening ? { prompt: opening } : {}), ...(parent ? { fromAgentId: parent.agentId } : {}) })
375 await apply($, task.id, { type: 'started', taskId: task.id, runId, model: '' })
376 return task.id
377}
378
379// ---- The 2D Order (plan §5): off by default; off loads no frame and schedules nothing ------------------------------------
380
381/** Art by agent id, loaded on first use while the 2D Order is on. */
382const artCache = new Map<string, Art | null>()
383/** When each agent last had an event: the Dormant clock counts from here (art/animator.ts). */
384const lastEventAt = new Map<string, number>()
385let stageRt: StageRuntime | undefined
386let stageAgent = ''
387let stageTimer: { cancel(): void } | undefined
388/** The surface colour the frames blend over, as 0xRRGGBB. */
389const panelColour = (theme: 'dark' | 'light'): number => Number.parseInt(palette(theme).surface.slice(1), 16)
390
391async function loadArt($: EngineInterface, agentId: string): Promise<Art | null> {
392 if (artCache.has(agentId)) return artCache.get(agentId) ?? null
393 let art: Art | null = null
394 try {
395 art = decodeArt(JSON.parse(await $.fs.read(`${$.plugin.root}/art/${agentId}.json`)))
396 } catch (err) {
397 $.ui.log(`legion-mod: no 2D art for ${agentId}: ${message(err)}`, { to: 'debug' })
398 }
399 artCache.set(agentId, art)
400 return art
401}
402
403function stopStage(): void {
404 stageTimer?.cancel()
405 stageTimer = undefined
406 stageRt = undefined
407 stageAgent = ''
408}
409
410/** Brings the stage in line with the settings and the view: starts, retargets, wakes or stops it. Cheap to call often. */
411async function stageVisible($: EngineInterface): Promise<boolean> {
412 if (!ctx.settings.twoD || ctx.ui.view !== 'order') return false
413 try {
414 return (await $.ui.panes()).some(p => p.id === 'legion' && p.isShown)
415 } catch {
416 return false // no surface to draw on (a headless session): the stage stays off
417 }
418}
419
420async function driveStage($: EngineInterface): Promise<void> {
421 if (!(await stageVisible($))) {
422 if (stageRt || stageTimer) stopStage()
423 return
424 }
425 const agentId = ctx.ui.agentId
426 const theme = ((await $.state.get(THEME)).value ?? 'dark') as 'dark' | 'light'
427 const opts = { panel: panelColour(theme), transparent: 'terminal' as const }
428 if (!stageRt || stageAgent !== agentId) {
429 stopStage()
430 const art = await loadArt($, agentId)
431 if (!art) { await $.state.set(STAGE, null); return }
432 const at = await $.clock.now()
433 // An agent with no events yet counts its quiet time from now; it must never read as "just active" on every frame.
434 if (!lastEventAt.has(agentId)) lastEventAt.set(agentId, at)
435 stageRt = createStage(art, at, ctx.moods[agentId]?.mood ?? 'idle', opts)
436 stageAgent = agentId
437 const first = stepStage(stageRt, at, { mood: ctx.moods[agentId]?.mood ?? 'idle', motion: ctx.settings.motion, lastEventAt: lastEventAt.get(agentId) ?? 0 })
438 await $.state.set(STAGE, { agentId, cells: first.cells ?? '', cols: art.stage.cols, rows: art.stage.rows })
439 scheduleStage($, first.nextAtMs, at)
440 return
441 }
442 // Already showing: an event may have woken a Dormant stage, so step now if nothing is scheduled.
443 if (!stageTimer) await frame($)
444}
445
446function scheduleStage($: EngineInterface, nextAtMs: number | null, at: number): void {
447 stageTimer?.cancel()
448 stageTimer = nextAtMs === null ? undefined : $.clock.after(Math.max(0, nextAtMs - at), () => { stageTimer = undefined; void frame($) })
449}
450
451/** One frame: blit it when it changed, then ask the animator when to come back (never, once Dormant). */
452async function frame($: EngineInterface): Promise<void> {
453 if (!stageRt) return
454 if (!(await stageVisible($))) { stopStage(); return } // the pane closed or moved on: nothing runs for a stage nobody sees
455 const at = await $.clock.now()
456 const agentId = stageAgent
457 const step = stepStage(stageRt, at, { mood: ctx.moods[agentId]?.mood ?? 'idle', motion: ctx.settings.motion, lastEventAt: lastEventAt.get(agentId) ?? 0 })
458 if (step.cells) {
459 const blitted = await $.ui.blit({ requestId: 'legion', key: 'stage', cells: step.cells })
460 if (blitted.deny) invalidateStage(stageRt) // the Raster is not mounted (view changed): redraw in full next time
461 }
462 scheduleStage($, step.nextAtMs, at)
463}
464
465/** The muster row's still frames: computed once per mood change, never animated (as the desktop's rail busts). */
466async function publishMuster($: EngineInterface): Promise<void> {
467 if (!ctx.settings.twoD) { await $.state.set(MUSTER, {}); return }
468 const theme = ((await $.state.get(THEME)).value ?? 'dark') as 'dark' | 'light'
469 const out: Record<string, string> = {}
470 for (const a of ctx.agents) {
471 if (a.isHidden) continue
472 const art = await loadArt($, a.id)
473 if (art) out[a.id] = musterCells(art, ctx.moods[a.id]?.mood ?? 'idle', { panel: panelColour(theme), transparent: 'terminal' })
474 }
475 await $.state.set(MUSTER, out)
476}
477
478// ---- Boot and refresh ----------------------------------------------------------------------------------------------------
479
480async function boot($: EngineInterface): Promise<void> {
481 const sessionId = await $.session.id()
482 const root = dataRoot({ LEGION_MOD_HOME: await $.env.get('LEGION_MOD_HOME'), USERPROFILE: await $.env.get('USERPROFILE'), HOME: await $.env.get('HOME') })
483 dataRootPath = root
484 const port = fsPort({
485 read: p => $.fs.read(p),
486 write: (p, t) => $.fs.write(p, t),
487 list: p => $.fs.list(p),
488 stat: p => $.fs.stat(p),
489 exists: p => $.fs.exists(p),
490 }, root)
491 stores = {
492 tasks: createTaskStore(port, sessionId),
493 threads: createThreadStore(port, sessionId),
494 settings: createSettingsStore(port, sessionId),
495 agents: createAgentStore(port, sessionId),
496 }
497 const settings = await stores.settings.load()
498 const previousUi = (await $.state.get({ plugin: 'legion-mod', key: 'ui' } as const)).value
499 ctx = newCtx(settings)
500 ctx.sessionId = sessionId
501 trace = newTrace((await $.env.get('LEGION_MOD_TRACE')) === '1' || trace.isOn)
502 ctx.agents = await stores.agents.load(seedAgents())
503 for (const t of await stores.tasks.load()) {
504 ctx.tasks.set(t.id, t)
505 if (t.runId) ctx.byRun.set(t.runId, t.id)
506 }
507 // A hot reload keeps $.state: the queue and answered results carry over, so nothing is spawned twice.
508 for (const req of (await $.state.get(QUEUE)).value ?? []) ctx.pending.set(req.id, req)
509 for (const r of ((await $.state.get(RESULTS as any)).value as unknown as RunResult[] | undefined) ?? []) await onResult($, r)
510 if (previousUi) ctx.ui = previousUi
511 // A hot reload keeps the band, the open cards and the moods too; only a fresh process starts them empty.
512 ctx.band = (await $.state.get(BAND)).value ?? ctx.band
513 ctx.cards = (await $.state.get(CARDS)).value ?? ctx.cards
514 ctx.moods = (await $.state.get(MOODS)).value ?? ctx.moods
515 // $.state lives as long as this Claude Code process: set here means a hot reload, unset means Claude Code (re)started.
516 if ((await $.state.get(SESSION)).value === undefined) await markGoneRuns($)
517 const claudeTheme = (await $.settings.read()) as { theme?: unknown }
518 await $.state.set(THEME, settings.theme === 'auto' ? themeFromClaude(claudeTheme.theme) : settings.theme)
519 await $.state.set(SETTINGS, settings)
520 await $.state.set(SESSION, sessionId)
521 await $.state.set(AGENTS, ctx.agents)
522 await $.state.set(UI, ctx.ui)
523 await $.state.set(MOODS, ctx.moods)
524 await $.state.set(BAND, ctx.band)
525 await $.state.set(CARDS, ctx.cards)
526 await publishTasks($)
527 await publishQueue($)
528 for (const a of ctx.agents) if (!a.isHidden) await $.agent.register(buildAgentSpec(a, settings))
529 ctx.isReady = true
530 await tr($, { k: 'boot', root, agents: ctx.agents.filter(a => !a.isHidden).length, tasks: ctx.tasks.size, pending: ctx.pending.size, twoD: settings.twoD })
531 // The 2D Order only when it is on: off loads no art and schedules nothing.
532 await $.state.set(STAGE, null)
533 if (settings.twoD) await publishMuster($)
534}
535
536/** Why a task of this session failed: Claude Code closed while its agent worked, so the agent is gone. */
537export const GONE_REASON = (agentId: string): string => `Claude Code closed while it worked. Send it again: /to ${agentId} <what to do>`
538
539/**
540 * After Claude Code restarts, this session's tasks that still read working have no agent behind them: their runs ended
541 * with the old process. Each one Claude Code no longer lists becomes failed, with why. Runs it still lists, tasks of other
542 * windows, and spawns still queued are left alone. If the list cannot be read, nothing is marked: guessing would fail a
543 * live task.
544 */
545async function markGoneRuns($: EngineInterface): Promise<void> {
546 const mine = [...ctx.tasks.values()].filter(t => t.sessionId === ctx.sessionId && (t.status === 'running' || t.status === 'queued'))
547 if (mine.length === 0) return
548 let listed: Set<string>
549 try {
550 const agents = await $.agent.list()
551 if (!Array.isArray(agents)) return
552 listed = new Set(agents.filter(a => !['completed', 'failed', 'killed'].includes(a.status)).map(a => a.id))
553 } catch {
554 return
555 }
556 const queued = new Set([...ctx.pending.values()].map(r => r.taskId))
557 const gone = mine.filter(t => !(t.runId && listed.has(t.runId)) && !queued.has(t.id))
558 if (gone.length === 0) return
559 const at = await $.clock.now()
560 for (const t of gone) {
561 const failed: TaskView = { ...t, status: 'error', error: GONE_REASON(t.agentId), updatedAt: at }
562 ctx.tasks.set(t.id, failed)
563 await stores?.tasks.put(failed)
564 }
565 await tr($, { k: 'gone', tasks: gone.map(t => t.id).join(',') })
566 // one row for the lot: a restart with five open tasks is one event, not five
567 const first = gone[0]!
568 await addBand($, gone.length === 1
569 ? { id: newId('b'), kind: 'error', taskId: first.id, agentId: first.agentId, text: `${first.title} · ${GONE_REASON(first.agentId)}`, at }
570 : { id: newId('b'), kind: 'inbox', text: `${gone.length} Legion tasks failed when Claude Code closed · open Order to see them`, at })
571}
572
573/** How long a request may wait while legion-mod-runner has never answered in this session, before Legion says so. */
574const RUNNER_SILENT_MS = 30_000
575export const RUNNER_MISSING = 'legion-mod-runner is not running, so agents cannot start. Install it: claude plugin install legion-mod-runner@legion'
576
577async function failIfRunnerMissing($: EngineInterface): Promise<void> {
578 if (ctx.pending.size === 0) return
579 const runner = await $.state.get({ plugin: 'legion-mod-runner', key: 'results' } as any)
580 if (runner.version > 0) return
581 const at = await $.clock.now()
582 for (const req of [...ctx.pending.values()]) {
583 if (at - req.at < RUNNER_SILENT_MS) continue
584 await onResult($, { requestId: req.id, kind: req.kind, taskId: req.taskId, ok: false, error: RUNNER_MISSING, at })
585 }
586}
587
588async function refresh($: EngineInterface): Promise<void> {
589 if (!stores || !ctx.isReady) return
590 await failIfRunnerMissing($)
591 try {
592 const changes = await stores.tasks.refresh()
593 if (changes.changed.length || changes.removed.length) {
594 for (const t of changes.changed) if (t.sessionId !== ctx.sessionId) ctx.tasks.set(t.id, t)
595 for (const id of changes.removed) ctx.tasks.delete(id)
596 await publishTasks($)
597 }
598 const s = await stores.settings.refresh()
599 if (s) { ctx.settings = s; await $.state.set(SETTINGS, s) }
600 const shown = ctx.ui.taskId ? ctx.tasks.get(ctx.ui.taskId) : undefined
601 if (shown && shown.sessionId !== ctx.sessionId) {
602 const rows = await stores.threads.refresh(shown.id)
603 if (rows) { ctx.threads.set(shown.id, rows); await publishThread($, shown.id) }
604 }
605 } catch (err) {
606 $.ui.log(`legion-mod: refresh failed: ${message(err)}`, { to: 'debug' })
607 }
608}
609
610// ---- Commands ------------------------------------------------------------------------------------------------------------
611
612/** Whether the Legion pane is on screen now (false where the surface cannot say). */
613async function paneShown($: EngineInterface): Promise<boolean> {
614 try {
615 return (await $.ui.panes()).some(p => p.id === 'legion' && p.isShown)
616 } catch {
617 return false
618 }
619}
620
621async function redrawTimes($: EngineInterface): Promise<void> {
622 try {
623 if (!(await $.ui.panes()).some(p => p.id === 'legion' && p.isShown)) return
624 $.ui.invalidate('ui.render')
625 } catch (err) {
626 $.ui.log(`legion-mod: redraw skipped: ${message(err)}`, { to: 'debug' })
627 }
628}
629
630async function openTask($: EngineInterface, taskId: string): Promise<void> {
631 const task = ctx.tasks.get(taskId)
632 if (!task) return
633 if (!ctx.threads.has(taskId) && stores) {
634 ctx.threads.set(taskId, await stores.threads.load(taskId))
635 await publishThread($, taskId)
636 }
637 // Selecting a task also selects its agent: the Chat view shows the selected agent's task only.
638 ctx.ui = { ...ctx.ui, view: 'chat', agentId: task.agentId, taskId, keysOpen: false }
639 await $.state.set(UI, ctx.ui)
640}
641
642/** Recent pokes per agent: five within ten seconds make it annoyed (the desktop Relic's rule of thumb). */
643const pokes = new Map<string, number[]>()
644
645/** What a Legion Button asked for (src/ui/actions.ts grammar). A press never spawns: it queues, or changes the view. */
646async function actOn($: EngineInterface, a: DecodedAction): Promise<void> {
647 switch (a.kind) {
648 case 'view':
649 ctx.ui = { ...ctx.ui, view: a.view, keysOpen: false }
650 await $.state.set(UI, ctx.ui)
651 await driveStage($)
652 return
653 case 'agent': {
654 const latest = latestTaskOf(ctx, a.agentId)
655 ctx.ui = { ...ctx.ui, agentId: a.agentId, taskId: latest?.id ?? null, keysOpen: false }
656 await $.state.set(UI, ctx.ui)
657 if (latest && ctx.ui.view === 'chat') await openTask($, latest.id)
658 await driveStage($)
659 return
660 }
661 case 'task':
662 return openTask($, a.taskId)
663 case 'new': {
664 const agent = ctx.agents.find(x => x.id === a.agentId)
665 if (agent) await $.prompt.fill({ text: `/to ${agent.id} `, mode: 'replace' })
666 return
667 }
668 case 'continue':
669 case 'stop': {
670 const task = ctx.tasks.get(a.taskId)
671 if (!task) return
672 const why = a.kind === 'continue' ? await continueTask($, task) : await stopTask($, task)
673 if (why) $.ui.toast(why)
674 return
675 }
676 case 'poke': {
677 const at = (await $.clock.now())
678 const recent = [...(pokes.get(a.agentId) ?? []).filter(t => at - t < 10_000), at]
679 pokes.set(a.agentId, recent)
680 await setMood($, a.agentId, recent.length >= 5 ? 'annoyed' : 'listening')
681 return
682 }
683 case 'card-allow':
684 case 'card-deny':
685 // Approvals are answered in Claude Code's own permission dialog (plan §2.1); the pane shows the request, not a second answer.
686 $.ui.toast("Needs your OK: answer it in Claude Code's permission dialog.")
687 return
688 case 'keys':
689 ctx.ui = { ...ctx.ui, keysOpen: a.open }
690 await $.state.set(UI, ctx.ui)
691 return
692 case 'steps':
693 ctx.ui = { ...ctx.ui, stepsOpen: a.taskId }
694 await $.state.set(UI, ctx.ui)
695 return
696 case 'band-dismiss': {
697 const id = 'itemId' in a ? a.itemId : bandItemAt(ctx.band, a.index)?.id
698 if (!id) return
699 ctx.band = ctx.band.filter(item => item.id !== id)
700 await $.state.set(BAND, ctx.band)
701 return
702 }
703 }
704}
705
706/** The tasks `taskId` handed out (tell or ask) that still work. */
707const openChildren = (taskId: string): TaskView[] =>
708 [...ctx.tasks.values()].filter(t => t.origin.kind === 'bridge' && t.origin.fromTaskId === taskId && (t.status === 'running' || t.status === 'queued'))
709
710/** If nothing wakes a held task this long after its last helper answered, it is settled as done. */
711const WAKE_GRACE_MS = 60_000
712
713/** Keeps a task working while the agents it told work: no done row or toast yet, and no victory. */
714async function holdForChildren($: EngineInterface, task: TaskView): Promise<void> {
715 const held: TaskView = { ...task, status: 'running', updatedAt: await $.clock.now() }
716 ctx.tasks.set(task.id, held)
717 await stores?.tasks.put(held)
718 await publishTasks($)
719 await setMood($, task.agentId, 'thinking')
720 await tr($, { k: 'hold', task: task.id, open: openChildren(task.id).map(t => t.id).join(',') })
721}
722
723/** The band row and, when the pane is hidden, the toast for a task that stopped. */
724async function settleTask($: EngineInterface, after: TaskView): Promise<void> {
725 const agent = ctx.agents.find(a => a.id === after.agentId)
726 const who = agent ? `${agent.glyph} ${agent.name}` : after.agentId
727 const kind = after.status === 'paused' ? 'paused' : after.status === 'error' ? 'error' : 'done'
728 const what = kind === 'paused' ? 'paused at the turn limit' : kind === 'error' ? `failed: ${after.error ?? 'no reason given'}` : 'done'
729 await addBand($, { id: newId('b'), kind, taskId: after.id, agentId: after.agentId, text: `${after.title} · ${what}`, at: (await $.clock.now()) })
730 if (!(await paneShown($))) $.ui.toast(`${who} · ${after.title} · ${what}`)
731}
732
733/**
734 * A told or asked agent stopped. Its answer reaches the caller through Claude Code itself, which wakes the caller's loop
735 * (seen live: a second delivery from Legion made Zealot answer twice), so Legion only carries the taint up, and settles
736 * a held caller that nothing woke within WAKE_GRACE_MS of its last helper's answer.
737 */
738async function afterChildFinished($: EngineInterface, child: TaskView): Promise<void> {
739 if (child.origin.kind !== 'bridge') return
740 const caller = ctx.tasks.get(child.origin.fromTaskId)
741 if (!caller) return
742 if (answerTaintsCaller(child) && !caller.isTainted) {
743 const tainted = { ...caller, isTainted: true, updatedAt: (await $.clock.now()) }
744 ctx.tasks.set(caller.id, tainted)
745 await stores?.tasks.put(tainted)
746 }
747 if (caller.status !== 'running' || openChildren(caller.id).length > 0) return
748 const mark = ctx.tasks.get(caller.id)?.updatedAt
749 $.clock.after(WAKE_GRACE_MS, () => void settleIfUnwoken($, caller.id, mark))
750}
751
752/** Settles a held task as done, unless a step or finish since `mark` shows Claude Code woke it (its own finish settles it). */
753async function settleIfUnwoken($: EngineInterface, taskId: string, mark: number | undefined): Promise<void> {
754 const task = ctx.tasks.get(taskId)
755 if (!task || task.status !== 'running' || task.updatedAt !== mark) return
756 const settled: TaskView = { ...task, status: 'done', updatedAt: await $.clock.now() }
757 ctx.tasks.set(taskId, settled)
758 await stores?.tasks.put(settled)
759 await publishTasks($)
760 await setMood($, task.agentId, 'victory')
761 await tr($, { k: 'settle', task: taskId })
762 await settleTask($, settled)
763}
764
765/** The oldest Claude Code that runs mods (plan-legion-mod-release.md D10). */
766const MIN_CLAUDE_CODE = [2, 1, 287] as const
767
768const versionAtLeast = (v: string, min: readonly number[]): boolean => {
769 const parts = v.split(/[.+-]/).map(n => Number.parseInt(n, 10))
770 for (let i = 0; i < min.length; i++) {
771 const a = parts[i] ?? 0
772 if (Number.isNaN(a)) return false
773 if (a !== min[i]) return a > min[i]!
774 }
775 return true
776}
777
778/** `/legion doctor`: what Legion needs, checked now. Each problem says what to do next. */
779async function doctor($: EngineInterface): Promise<DoctorLine[]> {
780 const lines: DoctorLine[] = []
781 const version = (await $.session.version()).version
782 lines.push(versionAtLeast(version, MIN_CLAUDE_CODE)
783 ? { ok: true, label: 'Claude Code', detail: version }
784 : { ok: false, label: 'Claude Code', detail: `${version}: mods need ${MIN_CLAUDE_CODE.join('.')} or newer. Run claude update.` })
785 const runner = await $.state.get({ plugin: 'legion-mod-runner', key: 'results' } as any)
786 lines.push(runner.version > 0
787 ? { ok: true, label: 'Runner', detail: 'legion-mod-runner is answering' }
788 : { ok: false, label: 'Runner', detail: 'legion-mod-runner is not running, so agents cannot start. Install it: claude plugin install legion-mod-runner@legion' })
789 try {
790 const probe = `${dataRootPath}/doctor-probe.txt`
791 const stamp = String(await $.clock.now())
792 await $.fs.write(probe, stamp)
793 const back = await $.fs.read(probe)
794 lines.push(back === stamp ? { ok: true, label: 'Data folder', detail: dataRootPath } : { ok: false, label: 'Data folder', detail: `${dataRootPath} did not read back what was written.` })
795 } catch (err) {
796 lines.push({ ok: false, label: 'Data folder', detail: `${dataRootPath || 'not set'}: ${message(err)}. Set LEGION_MOD_HOME to a folder you can write.` })
797 }
798 const skipped = (stores?.tasks.skipped ?? 0) + (stores?.threads.skipped ?? 0) + (stores?.settings.skipped ?? 0) + (stores?.agents.skipped ?? 0)
799 lines.push(skipped === 0 ? { ok: true, label: 'Stored data', detail: 'every line read cleanly' } : { ok: false, label: 'Stored data', detail: `${skipped} unreadable line${skipped === 1 ? '' : 's'} skipped; nothing else was lost.` })
800 for (const refused of refusedAtStart) lines.push({ ok: false, label: 'Start', detail: `Claude Code refused ${refused}` })
801 lines.push({ ok: null, label: 'Agents', detail: `${ctx.agents.filter(a => !a.isHidden).length} in the Order` })
802 lines.push({ ok: null, label: 'Cost estimates', detail: `prices as of ${PRICES_AS_OF}` })
803 return lines
804}
805
806/**
807 * The task `/legion continue` or `/legion stop` means. Named: a task id, or an agent (its newest task in this window). Unnamed: the task
808 * the pane shows when that is one it can act on, else the only one in this window it can act on. Two or more: it asks
809 * which, naming them, instead of guessing (a guess here continues or stops the wrong work).
810 */
811function pickTask(args: string, verb: 'continue' | 'stop', shown: TaskView | undefined): { task: TaskView } | { error: string } {
812 const mine = taskList(ctx).filter(t => t.sessionId === ctx.sessionId)
813 const can = (t: TaskView) => (verb === 'stop' ? t.status === 'running' || t.status === 'queued' : t.status === 'paused' || t.status === 'done' || t.status === 'error' || t.status === 'cancelled')
814 const ref = args.trim()
815 if (ref) {
816 const byId = ctx.tasks.get(ref)
817 if (byId) return { task: byId }
818 const agent = resolveAgent(ref, ctx.agents)
819 const latest = agent ? mine.find(t => t.agentId === agent.id) : undefined
820 if (latest) return { task: latest }
821 return { error: agent ? `${agent.glyph} ${agent.name} has no task in this window.` : `No task or agent called "${ref}".` }
822 }
823 if (shown && shown.sessionId === ctx.sessionId && can(shown)) return { task: shown }
824 const candidates = verb === 'stop' ? mine.filter(can) : mine.filter(t => t.status === 'paused')
825 if (candidates.length === 1) return { task: candidates[0]! }
826 if (candidates.length === 0) return { error: verb === 'stop' ? 'Nothing is working in this window.' : 'Nothing is paused in this window. Open a task in the Legion pane, or name one: /legion continue builder' }
827 const names = candidates.slice(0, 4).map(t => `${ctx.agents.find(a => a.id === t.agentId)?.id ?? t.agentId} (${t.title})`).join(', ')
828 return { error: `Which one? Name it: /legion ${verb} ${candidates[0]!.agentId}. In this window: ${names}${candidates.length > 4 ? ', …' : ''}.` }
829}
830
831async function runCommand($: EngineInterface, command: string, args: string): Promise<{ text: string }> {
832 await tr($, { k: 'cmd', command, args })
833 if (!ctx.isReady) return { text: 'Legion is still starting. Try again in a moment.' }
834 if (command === 'to') {
835 const parsed = parseTo(args, ctx.agents)
836 if ('error' in parsed) return { text: parsed.error }
837 const task = await startTask($, parsed.agent, parsed.text)
838 return { text: `Sent to ${parsed.agent.glyph} ${parsed.agent.name} · task ${task.id}` }
839 }
840 const shownAgent = ctx.agents.find(a => a.id === ctx.ui.agentId) ?? ctx.agents[0]
841 const shownTask = ctx.ui.taskId ? ctx.tasks.get(ctx.ui.taskId) : shownAgent ? latestTaskOf(ctx, shownAgent.id) : undefined
842 if (command === 'say') {
843 const text = args.trim()
844 if (!text) return { text: 'Usage: /say <message>. It goes to the agent the Legion pane shows.' }
845 if (!shownAgent) return { text: 'No agent to talk to.' }
846 if (shownTask && shownTask.agentId === shownAgent.id && shownTask.runId && shownTask.status !== 'running' && shownTask.status !== 'queued') {
847 const why = await continueTask($, shownTask, text)
848 return { text: why || `Sent to ${shownAgent.glyph} ${shownAgent.name} · task ${shownTask.id}` }
849 }
850 const task = await startTask($, shownAgent, text)
851 return { text: `Sent to ${shownAgent.glyph} ${shownAgent.name} · task ${task.id}` }
852 }
853 if (command === 'continue' || command === 'stop') {
854 const pick = pickTask(args, command === 'continue' ? 'continue' : 'stop', shownTask)
855 if ('error' in pick) return { text: pick.error }
856 const why = command === 'continue' ? await continueTask($, pick.task) : await stopTask($, pick.task)
857 const agent = ctx.agents.find(a => a.id === pick.task.agentId)
858 const who = agent ? `${agent.glyph} ${agent.name}` : pick.task.agentId
859 return { text: why || `${command === 'continue' ? 'Continuing' : 'Stopping'} ${who} · ${pick.task.title} · task ${pick.task.id}` }
860 }
861 // /legion [continue|stop [task|agent] | view | talk <agent> | talk off | doctor | motion on|off | 2d on|off]
862 const [sub = '', rest = ''] = [args.trim().split(/\s+/)[0] ?? '', args.trim().split(/\s+/).slice(1).join(' ')]
863 if (sub === 'continue' || sub === 'stop') return runCommand($, sub, rest)
864 const views: Record<string, ViewId> = { chat: 'chat', order: 'order' }
865 if (views[sub]) { ctx.ui = { ...ctx.ui, view: views[sub] }; await $.state.set(UI, ctx.ui); await driveStage($) }
866 if (sub === 'talk') {
867 if (rest === 'off' || rest === '') {
868 ctx.ui = { ...ctx.ui, channel: null }
869 await $.state.set(UI, ctx.ui)
870 return { text: 'Channel closed. Prompts go to Claude Code again.' }
871 }
872 const agent = resolveAgent(rest, ctx.agents)
873 if (!agent) return { text: `No agent called "${rest}".` }
874 ctx.ui = { ...ctx.ui, channel: agent.id, agentId: agent.id }
875 await $.state.set(UI, ctx.ui)
876 return { text: `Talking to ${agent.glyph} ${agent.name}. Every prompt goes to ${agent.name} until /legion talk off.` }
877 }
878 if (sub === 'trace') {
879 if (rest !== 'on' && rest !== 'off') return { text: 'Usage: /legion trace on|off. The trace is for development: every decision, one line each, in the data folder.' }
880 trace.isOn = rest === 'on'
881 if (trace.isOn) await tr($, { k: 'boot', root: dataRootPath, agents: ctx.agents.filter(a => !a.isHidden).length, tasks: ctx.tasks.size, pending: ctx.pending.size, twoD: ctx.settings.twoD, via: 'command' })
882 return { text: trace.isOn ? `Trace on: ${dataRootPath}/${traceFile(ctx.sessionId)}. Read it with node scripts/mod-trace.mjs.` : 'Trace off.' }
883 }
884 if (sub === 'doctor') {
885 const lines = await doctor($)
886 await $.state.set(DOCTOR, lines)
887 const bad = lines.filter(l => l.ok === false).length
888 return { text: lines.map(l => `${l.ok === true ? '✓' : l.ok === false ? '✕' : '·'} ${l.label}: ${l.detail}`).join('\n') + (bad ? '' : '\nAll clear.') }
889 }
890 if (sub === 'motion' || sub === '2d') {
891 const on = rest === 'on'
892 if (rest !== 'on' && rest !== 'off') return { text: `Usage: /legion ${sub} on|off` }
893 const patch: Partial<ModSettings> = sub === 'motion' ? { motion: on } : { twoD: on }
894 if (stores) ctx.settings = await stores.settings.patch(patch)
895 await $.state.set(SETTINGS, ctx.settings)
896 await publishMuster($)
897 await driveStage($)
898 return { text: sub === 'motion' ? `Motion ${on ? 'on' : 'off'}.` : `2D Order ${on ? 'on' : 'off'}.` }
899 }
900 await $.ui.open({ id: 'legion', title: 'Legion' })
901 await driveStage($)
902 return { text: 'Legion opened.' }
903}
904
905async function commandHook($: EngineInterface, command: string, args: string | undefined): Promise<{ text: string }> {
906 try {
907 return await runCommand($, command, args ?? '')
908 } catch (err) {
909 return { text: `Legion: ${message(err)}` }
910 }
911}
912
913// ---- Registration --------------------------------------------------------------------------------------------------------
914
915/**
916 * Legion's commands. Only `/legion`, `/to` and `/say` are top-level: Claude Code owns `/continue` (an alias of /resume) and
917 * refuses a plugin that registers it (live run, 2026-10-05), so continue and stop live under /legion with the other actions.
918 */
919export const COMMANDS = [
920 { name: 'legion', description: 'Open Legion, or act on a task: continue, stop, talk, doctor', argumentHint: '[continue|stop [task|agent]] [talk <agent>|off] [chat|order] [doctor]' },
921 { name: 'to', description: 'Send a message to one of Legion\'s agents as a new task', argumentHint: '<agent> <message>' },
922 { name: 'say', description: 'Send a message to the agent the Legion pane shows', argumentHint: '<message>' },
923] as const
924
925/** Registrations Claude Code refused at start (a name it owns, a tool it rejects): said once, and listed by /legion doctor. */
926const refusedAtStart: string[] = []
927
928export function registerLegion(on: Parameters<Register>[0]): void {
929 on('session.start', async ($, e, next) => {
930 const started = await next(e)
931 try {
932 await boot($)
933 } catch (err) {
934 $.ui.log(`legion-mod: Legion could not start: ${message(err)}`, { to: 'transcript' })
935 }
936 // Each registration on its own: one refusal must never take the rest of Legion's start down with it.
937 for (const c of COMMANDS) {
938 try {
939 await $.command.register(c)
940 } catch (err) {
941 refusedAtStart.push(`/${c.name}: ${message(err)}`)
942 }
943 }
944 try {
945 await $.tool.register({
946 name: 'agents',
947 description: 'List the agents of Legion\'s order (id, name, what each is for) and which are busy. Ask one with the Agent tool: subagent_type legion-mod:<id>, run_in_background false to wait for its answer, true to hand work off.',
948 })
949 } catch (err) {
950 refusedAtStart.push(`the agents tool: ${message(err)}`)
951 }
952 if (refusedAtStart.length) $.ui.log(`legion-mod: Claude Code refused ${refusedAtStart.join('; ')}. /legion doctor has the details.`, { to: 'transcript' })
953 $.clock.every(REFRESH_MS, () => void refresh($))
954 // Relative times ("5m") are drawn from the clock: redraw the open pane twice a minute so they stay true. Nothing runs when it is closed.
955 $.clock.every(30_000, () => void redrawTimes($))
956 return started
957 })
958
959 // One registration per command, with literal matchers, so validate lists each.
960 on('command.run', { command: 'legion' }, ($, e) => commandHook($, 'legion', e.args))
961 on('command.run', { command: 'to' }, ($, e) => commandHook($, 'to', e.args))
962 on('command.run', { command: 'say' }, ($, e) => commandHook($, 'say', e.args))
963
964 // The trace's last lines: flushed at session end (Claude Code bounds session.end to about 1.5 s; one write fits).
965 on('session.end', async ($, e, next) => {
966 if (trace.isOn && trace.isDirty) await flushTrace($)
967 return next(e)
968 })
969
970 // Every Legion Button: decode its key and act. The Button's own onPress is a no-op; this hook answers the press.
971 // Matched by plugin, not by requestId: only the pane is 'legion'; the band above the prompt and a /to card are
972 // other sites with their own ids, and a requestId matcher left their buttons (dismiss, open, continue) dead.
973 on('ui.press', { plugin: 'legion-mod' }, async ($, e, next) => {
974 const action = decodeAction(e.element)
975 if (!action || e.plugin !== 'legion-mod') return next(e)
976 try {
977 await actOn($, action)
978 } catch (err) {
979 $.ui.toast(`Legion: ${message(err)}`)
980 }
981 return next(e)
982 })
983
984 // Runner results: applied once each. This hook is caused by the runner's write, so it never makes this plugin a spawn's cause.
985 on('state.set', RESULTS_MATCH as any, async ($, e, next) => {
986 const done = await next(e)
987 for (const r of ((e as unknown as { value?: RunResult[] }).value) ?? []) await onResult($, r)
988 return done
989 })
990
991 on('tool.call', { tool: 'mcp__legion-mod__agents' as any }, async () => {
992 const text = agentsList('', ctx.agents, taskList(ctx))
993 return { result: text }
994 })
995
996 on('tool.call', async ($, e, next) => {
997 const runId = e.agentId
998 const taskId = runId ? ctx.byRun.get(runId) ?? (await adopt($, runId)) : undefined
999 const task = taskId ? ctx.tasks.get(taskId) : undefined
1000 if (!task || !runId) return next(e)
1001 ctx.toolRun.set(e.tool_use_id, runId)
1002 // A Legion ask or tell: the desktop bridge's guards decide (bridge.ts), with the desktop's messages.
1003 if (e.tool === 'Agent') {
1004 const input = e as unknown as { subagent_type?: string; prompt?: string; run_in_background?: boolean }
1005 const target = parseLegionAgentType(input.subagent_type)
1006 // A Legion agent delegates inside the Order only: a built-in type (general-purpose, Explore) would run outside Legion,
1007 // untracked and unguarded. A live run on 2026-10-05 showed Zealot reaching for general-purpose.
1008 if (!target) {
1009 const reason = orderOnlyMessage(input.subagent_type, ctx.agents)
1010 await tr($, { k: 'deny', task: task.id, agent: task.agentId, run: runId, tool: e.tool, subagentType: input.subagent_type, reason })
1011 return { deny: reason }
1012 }
1013 const check = checkAsk({ callerRunId: runId, target, isBlocking: input.run_in_background === false, message: input.prompt ?? '', agents: ctx.agents, tasks: taskList(ctx), waiting: ctx.waiting, rateLog: ctx.rateLog, now: (await $.clock.now()) })
1014 ctx.rateLog = check.rateLog
1015 if (!check.ok) {
1016 await tr($, { k: 'deny', task: task.id, agent: task.agentId, run: runId, tool: e.tool, subagentType: input.subagent_type, reason: check.reason })
1017 return { deny: check.reason }
1018 }
1019 if (input.run_in_background === false) ctx.waiting.add(task.id)
1020 }
1021 if (taintsRun(e.tool) && !task.isTainted) {
1022 const tainted = { ...task, isTainted: true, updatedAt: (await $.clock.now()) }
1023 ctx.tasks.set(task.id, tainted)
1024 await stores?.tasks.put(tainted)
1025 await publishTasks($)
1026 }
1027 const summary = toolLine(e.tool, toolInput(e as unknown as Record<string, unknown>), ctx.agents, summarizeToolInput)
1028 const agentCall = e.tool === 'Agent' ? (e as unknown as { subagent_type?: string; run_in_background?: boolean }) : undefined
1029 await tr($, { k: 'tool', task: task.id, agent: task.agentId, run: runId, tool: e.tool, id: e.tool_use_id, summary, subagentType: agentCall?.subagent_type, background: agentCall?.run_in_background })
1030 await apply($, task.id, { type: 'tool', runId, toolUseId: e.tool_use_id, tool: e.tool, summary })
1031 const ran = await next(e)
1032 ctx.waiting.delete(task.id)
1033 // A no in Claude Code's dialog comes back as an error result, not a hook's deny: either way the card was not allowed,
1034 // so the agent goes back to thinking instead of showing Executing (desktop's Deny rule, 2026-10-06).
1035 if (ctx.cards.some(c => c.id === e.tool_use_id)) await closeCard($, task.id, e.tool_use_id, ran.deny === undefined && ran.isError !== true)
1036 const failed = ran.deny !== undefined || ran.isError === true
1037 await tr($, { k: 'toolDone', task: task.id, run: runId, tool: e.tool, id: e.tool_use_id, isError: failed, deny: ran.deny })
1038 await apply($, task.id, { type: 'toolDone', runId, toolUseId: e.tool_use_id, isError: failed })
1039 return ran
1040 })
1041
1042 // Legion's own tools never ask; a run under a stricter ceiling asks where its agent's mode would not (approvals.ts extraAsk).
1043 // The person's own Claude Code rules decide first (next). A deny stands, always. Legion then only tightens: an allow or an ask
1044 // becomes an ask where Legion's mode needs a card the agent's permission mode would not raise; Legion's own tools are let
1045 // through where the person's rules would only ask (never where they deny).
1046 on('tool.check', async ($, e, next) => {
1047 const runId = e.tool_use_id ? ctx.toolRun.get(e.tool_use_id) : undefined
1048 const task = runId ? ctx.tasks.get(ctx.byRun.get(runId) ?? '') : undefined
1049 const verdict = await next(e)
1050 if (!task || verdict.decision === 'deny') return verdict
1051 // Legion's own tools pass the engine's default ask, never a rule the person wrote (a rule names itself in `rule`).
1052 if (isLegionModTool(e.tool)) return verdict.decision === 'ask' && !verdict.rule ? { decision: 'allow', reason: 'Legion\'s own tool' } : verdict
1053 const agent = ctx.agents.find(a => a.id === task.agentId)
1054 if (!agent) return verdict
1055 const ceiling = task.origin.kind === 'bridge' || task.origin.kind === 'room' ? 'ask' : agent.approval
1056 const effective = stricterMode(agent.approval, ceiling)
1057 const answer = verdict.decision === 'allow' && needsApproval(effective, e.tool)
1058 ? { decision: 'ask' as const, reason: `${agent.glyph} ${agent.name}, a Legion agent, needs your OK` }
1059 : verdict
1060 if (answer.decision === 'ask' && e.tool_use_id) {
1061 if (answer !== verdict) await tr($, { k: 'deny', task: task.id, agent: agent.id, run: runId, tool: e.tool, reason: 'asks: stricter ceiling', ceiling: effective })
1062 // tool.check carries the call's arguments under `input` (a tool.call carries them at the top level): summarize those.
1063 const args = (e as unknown as { input?: Record<string, unknown> }).input ?? toolInput(e as unknown as Record<string, unknown>)
1064 await openCard($, task, e.tool_use_id, e.tool, args)
1065 }
1066 return answer
1067 })
1068
1069 on('turn.step', async function* ($, e, next) {
1070 // The first model request is the first sign of a run Legion did not queue: adopt it here, so its turns all count.
1071 const taskId = e.agentId ? ctx.byRun.get(e.agentId) ?? (await adopt($, e.agentId)) : undefined
1072 if (!taskId || !e.agentId) return yield* next(e)
1073 const runId = e.agentId
1074 await apply($, taskId, { type: 'step', runId }, { persistRows: false })
1075 await tr($, { k: 'step', task: taskId, run: runId, n: ctx.tasks.get(taskId)?.runTurns, model: e.model })
1076 let text = ''
1077 const stream = next(e)
1078 while (true) {
1079 const item = await stream.next()
1080 if (item.done) {
1081 ctx.lastStop.set(runId, item.value?.stopReason ?? null)
1082 ctx.live.delete(taskId)
1083 await $.state.set({ ...LIVE, id: taskId }, '')
1084 return item.value
1085 }
1086 const chunk = item.value
1087 if (chunk.kind === 'text') {
1088 text += chunk.text
1089 const at = (await $.clock.now())
1090 if (at - liveAt >= LIVE_MS) {
1091 liveAt = at
1092 ctx.live.set(taskId, text)
1093 await $.state.set({ ...LIVE, id: taskId }, text)
1094 }
1095 }
1096 yield chunk
1097 }
1098 })
1099
1100 on('session.append', async ($, e, next) => {
1101 const stored = await next(e)
1102 const runId = e.agentId
1103 const taskId = runId ? ctx.byRun.get(runId) : undefined
1104 if (taskId && runId && e.door === 'response' && e.message.role === 'assistant') {
1105 const text = assistantText(e.message.content)
1106 if (text) await apply($, taskId, { type: 'reply', runId, text })
1107 }
1108 return stored
1109 })
1110
1111 on('turn.complete', async ($, e, next) => {
1112 const done = await next(e)
1113 const runId = e.agentId
1114 const taskId = runId ? ctx.byRun.get(runId) : undefined
1115 const task = taskId ? ctx.tasks.get(taskId) : undefined
1116 if (!task || !runId || !taskId) return done
1117 const isTurnLimit = inferTurnLimit({ reason: e.reason, runTurns: task.runTurns, maxTurns: ctx.settings.maxTurns, lastStopReason: ctx.lastStop.get(runId) })
1118 const usage = 'usage' in e && e.usage ? tokensFromUsage(e.usage) : undefined
1119 const model = 'usage' in e && e.usage && typeof e.usage.model === 'string' ? e.usage.model : undefined
1120 const after = await apply($, taskId, { type: 'finished', runId, reason: e.reason, answer: e.answer, isTurnLimit, ...(usage ? { usage } : {}), ...(model ? { model } : {}) })
1121 await tr($, { k: 'finish', task: taskId, agent: after?.agentId, run: runId, reason: e.reason, status: after?.status, turns: after?.turns, runTurns: task.runTurns, isTurnLimit, cost: after?.costUsd, answer: e.answer })
1122 if (!after) return done
1123 // Its turn ended, but agents it told still work: Claude Code wakes it with their answers, so it is not done yet
1124 // (live run: Zealot read "done" with "standing by", then answered again once Scout replied).
1125 if (after.status === 'done' && openChildren(after.id).length > 0) {
1126 await holdForChildren($, after)
1127 return done
1128 }
1129 await settleTask($, after)
1130 await afterChildFinished($, after)
1131 return done
1132 })
1133
1134 // Legion's own finished runs do not wake the main conversation; the band and the pane carry them. Channel mode routes prompts.
1135 on('prompt.submit', async ($, e, next) => {
1136 if (e.origin?.kind === 'task-notification') {
1137 const runId = notificationRunId(e.text)
1138 if (runId && ctx.byRun.has(runId)) {
1139 const task = ctx.tasks.get(ctx.byRun.get(runId) ?? '')
1140 const agent = task ? ctx.agents.find(a => a.id === task.agentId) : undefined
1141 await tr($, { k: 'drop', run: runId, task: task?.id })
1142 // Claude Code always shows a dropped prompt's reason (prompt.submit docs), so the line names what stopped, once.
1143 return { drop: task && agent ? finishedLine(agent, task) : "A Legion agent's result is in the Legion pane." }
1144 }
1145 }
1146 if (e.origin?.kind === 'composer' && ctx.ui.channel && ctx.isReady && !e.text.trimStart().startsWith('/')) {
1147 const agent = ctx.agents.find(a => a.id === ctx.ui.channel)
1148 if (agent) {
1149 const task = latestTaskOf(ctx, agent.id)
1150 const why = task && task.runId && task.status !== 'running' && task.status !== 'queued' ? await continueTask($, task, e.text) : (await startTask($, agent, e.text), '')
1151 if (why) $.ui.toast(why)
1152 await tr($, { k: 'channel', agent: agent.id, text: e.text, refused: why || undefined })
1153 return { drop: `Sent to ${agent.name} (Legion channel).` }
1154 }
1155 }
1156 return next(e)
1157 })
1158}
1159types/index.d.ts 221 lines1/**
2 * The Legion mod's contract: every domain type the mod's modules share, and every value it keeps in `$.state`.
3 *
4 * One home for these shapes. Store, engine and UI modules import types from here (type-only imports, erased at run time), so
5 * Node's test runner and the Claude Code engine read the same definitions. Values in `$.state` are plain JSON: no functions,
6 * no class instances, no secrets (any plugin can read `$.state`).
7 */
8
9/** A Legion agent id: the roster ids (`zealot`, `builder`, ...) or a person's own agents. */
10export type AgentId = string
11
12/** Legion's approval modes, as on the desktop (src/shared/types.ts ApprovalMode). */
13export type ApprovalMode = 'ask' | 'auto-edits' | 'full'
14
15/** The Relic's moods, worded on the desktop in useRelicState.ts; the mod shows the same words. */
16export type Mood = 'idle' | 'listening' | 'thinking' | 'hacking' | 'awaiting' | 'victory' | 'error' | 'sleeping' | 'annoyed'
17
18/** An agent as the mod shows and runs it. Seeded from the desktop roster; a person's edits are kept and never overwritten. */
19export type AgentView = {
20 id: AgentId
21 name: string
22 /** One roster glyph (✠ ⌘ ◎ ...), or the agent's own emoji for a person's agent. */
23 glyph: string
24 description: string
25 /** The agent's own prompt (role text), without Legion's preamble, which the engine adds per run. */
26 systemPrompt: string
27 /** `auto` (Legion's router picks per task), or an alias / model id. */
28 model: string
29 approval: ApprovalMode
30 /** True for the 13 roster agents. */
31 isRoster: boolean
32 /** Hidden agents (the Assayer needs BSV, which the mod leaves out) are not offered. */
33 isHidden: boolean
34}
35
36/** Where a task stands. `paused` is the turn limit: the work is kept and Continue picks it up. */
37export type TaskStatus = 'queued' | 'running' | 'done' | 'error' | 'cancelled' | 'paused'
38
39/** Token counts as the engine reports them per turn (turn.complete usage). */
40export type TokenCount = { input: number; output: number; cacheRead: number; cacheWrite: number }
41
42/** One Legion task: a thread with one agent, made of one Claude Code subagent run and its resumes. */
43export type TaskView = {
44 /** Legion's task id (`t_` + 12 hex). Stable across resumes. */
45 id: string
46 agentId: AgentId
47 title: string
48 status: TaskStatus
49 /** The Claude Code subagent id of the latest run, used to resume, stop and follow it. Absent while queued. */
50 runId?: string
51 /** The session (terminal window) that owns the run. Other windows show the task read-only. */
52 sessionId: string
53 /** The model the latest run used, as the engine resolved it. */
54 model?: string
55 /** True once the router escalated this task from sonnet to opus (once per task, as on the desktop). */
56 isEscalated: boolean
57 /** True once the run touched a tool that taints (web, shell, other MCP): its Library writes go to the Inbox. */
58 isTainted: boolean
59 turns: number
60 /** Model requests in the current run only; reset when a run starts or continues. The turn-limit check reads it. */
61 runTurns: number
62 tokens: TokenCount
63 /** An estimate from a price table: the engine reports tokens, not dollars, per agent. Always shown with "≈". */
64 costUsd: number
65 /** The last error, or the turn-limit line for a paused task. */
66 error?: string
67 /** Who started it: the person, another agent through the bridge, or a room wake. */
68 origin: TaskOrigin
69 projectId?: string
70 createdAt: number
71 updatedAt: number
72}
73
74export type TaskOrigin =
75 | { kind: 'person' }
76 | { kind: 'bridge'; fromAgentId: AgentId; fromTaskId: string; hop: number; depth: number }
77 | { kind: 'room'; roomId: string; hop: number }
78 /** Claude Code's own model delegated to a Legion agent with the Agent tool; Legion adopted the run so it shows in the pane. */
79 | { kind: 'claude-code' }
80
81/** One row of a thread as the Chat view draws it. */
82export type ThreadRow = {
83 id: string
84 role: 'user' | 'assistant' | 'tool' | 'system'
85 text: string
86 /** Set on tool rows. */
87 tool?: { name: string; summary: string; state: 'running' | 'ok' | 'error' | 'awaiting' | 'denied' }
88 /** Set on a user row a bridge or room delivered: who it came from. */
89 fromAgentId?: AgentId
90 at: number
91}
92
93/** An approval waiting for the person. */
94export type ApprovalCard = {
95 /** The tool_use_id of the call it holds. */
96 id: string
97 taskId: string
98 agentId: AgentId
99 tool: string
100 /** What the call does, at most 400 characters (the desktop's summarizeToolInput rule). */
101 summary: string
102 /** Who asked, in words: "Asked by Zealot in #release, hop 2". Absent for a task the person started. */
103 origin?: string
104 /** True where the A hotkey must not allow it (the desktop's click-only rule): the person moves to the button and presses Enter. */
105 isClickOnly: boolean
106 at: number
107}
108
109export type ViewId = 'chat' | 'rooms' | 'library' | 'board' | 'order'
110
111/** What the pane shows, and the channel the prompt talks to. */
112export type UiState = {
113 view: ViewId
114 agentId: AgentId
115 taskId: string | null
116 /** While set, every prompt goes to this agent (`/legion talk <agent>`). */
117 channel: AgentId | null
118 /** The key-help view (k) is open. */
119 keysOpen?: boolean
120 /** The task whose finished tool lines are unfolded, or null. */
121 stepsOpen?: string | null
122}
123
124/** One row of the band above the prompt: something that needs the person. */
125export type BandItem = {
126 id: string
127 kind: 'card' | 'done' | 'paused' | 'error' | 'inbox'
128 taskId?: string
129 agentId?: AgentId
130 text: string
131 at: number
132}
133
134/** An agent's live mood, with when it started (moods dwell at least 1.8 s, urgent ones switch at once). */
135export type MoodState = { mood: Mood; since: number; note?: string }
136
137/** One line of `/legion doctor`: ok, a problem with its next step, or informational (null). */
138export type DoctorLine = { ok: boolean | null; label: string; detail: string }
139
140/** The mod's own settings, kept across sessions. */
141export type ModSettings = {
142 /** `auto` follows Claude Code's theme setting. */
143 theme: 'auto' | 'dark' | 'light'
144 /** Ambient motion (pulse, muster, seal). Off freezes everything. */
145 motion: boolean
146 /** The 2D Order: painted busts drawn in cells. Off by default; off loads no frames and registers no timer. */
147 twoD: boolean
148 /** Turns per run before a task pauses at the turn limit (desktop: claude.maxTurns, 1-1000). */
149 maxTurns: number
150 /** What Builder's `full` means here: `acceptEdits` with commands still carded (safe default), or Claude Code's `auto`. */
151 fullMode: 'acceptEdits' | 'auto'
152}
153
154// ---- Shared with legion-mod-runner: keep identical to mod-runner/types/index.d.ts (a spec checks) ----
155/** A lifecycle request Legion Mod queues for the runner (legion-mod's `runQueue`). */
156export type RunRequest = {
157 /** Request id (`rq_` + 12 hex). The runner answers each id once. */
158 id: string
159 kind: 'spawn' | 'resume' | 'stop'
160 /** Legion's task id the request belongs to. */
161 taskId: string
162 /** spawn: the agent type, always `legion-mod:<agent id>`. */
163 agentType?: string
164 /** spawn: the name the run is addressable by. */
165 name?: string
166 /** spawn and resume: the text the agent receives. */
167 prompt?: string
168 /** spawn: an alias (`sonnet`, `opus`, `haiku`) or a model id. Absent: the agent type's own model. */
169 model?: string
170 /** spawn: the one-line description shown in Claude Code's agent list. */
171 description?: string
172 /** resume and stop: the Claude Code agent id of the run. */
173 runId?: string
174 at: number
175}
176
177/** What the runner did with one request (legion-mod-runner's `results`). */
178export type RunResult = {
179 requestId: string
180 kind: RunRequest['kind']
181 taskId: string
182 ok: boolean
183 /** spawn: the new run's agent id. resume and stop: the run acted on. */
184 runId?: string
185 model?: string
186 /** A plain sentence when ok is false. */
187 error?: string
188 at: number
189}
190
191// ---- end shared ----
192
193declare module 'claude-code' {
194 interface PluginState {
195 'legion-mod': {
196 agents: AgentView[]
197 tasks: TaskView[]
198 threads: StateFamily<ThreadRow[]>
199 /** Streaming text of a running task's current reply, cleared when the reply lands as a row. */
200 live: StateFamily<string>
201 cards: ApprovalCard[]
202 ui: UiState
203 moods: Record<AgentId, MoodState>
204 band: BandItem[]
205 settings: ModSettings
206 /** This window's Claude Code session id: tasks owned by another session are drawn read-only ("running in another window"). */
207 sessionId: string
208 /** The 2D Order's stage for the shown agent: its first frame (later frames are blitted, never written here). Null when 2D is off. */
209 stage: { agentId: AgentId; cells: string; cols: number; rows: number } | null
210 /** The muster row's still frames (Raster cells, base64) by agent. Empty when 2D is off. */
211 muster: Record<AgentId, string>
212 /** The last `/legion doctor` result, newest run only. */
213 doctor: DoctorLine[]
214 /** Resolved theme for drawing: `dark` or `light`. */
215 theme: 'dark' | 'light'
216 /** Lifecycle requests for legion-mod-runner, which acts on them from its own timer (plan §1b). Cleared once answered. */
217 runQueue: RunRequest[]
218 }
219 }
220}
221src/theme.ts 77 lines1/**
2 * Legion's look in a terminal: the desktop's tokens (ui/src/styles/tokens.css), unchanged, and the rules for using them.
3 *
4 * Rules (plan §6):
5 * - Accent (phosphor green) means "alive and yours": focus, the running pulse, the selection, connected. Never danger.
6 * - Hierarchy comes from weight, dim, accent and space only. No ALL-CAPS except the wordmark.
7 * - Plain words for state. Character only in the mascot quips and empty states.
8 */
9import type { Mood } from '../types/index.d.ts'
10
11export type Tone = 'bg' | 'surface' | 'surface2' | 'surface3' | 'line' | 'lineStrong' | 'text' | 'text2' | 'muted' | 'faint' | 'accent' | 'accentText' | 'accentInk' | 'warn' | 'danger'
12export type Palette = Record<Tone, string>
13
14/** ui/src/styles/tokens.css `:root` (dark, the default). */
15export const DARK: Palette = {
16 bg: '#0b0d10', surface: '#12151a', surface2: '#171b21', surface3: '#1d222a',
17 line: '#1e232b', lineStrong: '#2a313b',
18 text: '#e6e9ef', text2: '#b3bac6', muted: '#8a93a3', faint: '#7d8797',
19 accent: '#7CFFB2', accentText: '#7CFFB2', accentInk: '#04140b',
20 warn: '#FFCC66', danger: '#FF6B6B',
21}
22
23/** ui/src/styles/tokens.css `:root[data-theme='light']`. */
24export const LIGHT: Palette = {
25 bg: '#f4f5f7', surface: '#ffffff', surface2: '#f6f7f9', surface3: '#eceef2',
26 line: '#e3e6eb', lineStrong: '#cfd4dc',
27 text: '#14171c', text2: '#3a4250', muted: '#596275', faint: '#626c7e',
28 accent: '#0c9f5e', accentText: '#087a47', accentInk: '#ffffff',
29 warn: '#8f5d0c', danger: '#c0343e',
30}
31
32export const palette = (theme: 'dark' | 'light'): Palette => (theme === 'light' ? LIGHT : DARK)
33
34/** Claude Code's theme names that start with `light` are light terminals; everything else draws on dark. */
35export const themeFromClaude = (name: unknown): 'dark' | 'light' => (typeof name === 'string' && name.startsWith('light') ? 'light' : 'dark')
36
37/** The mood words of the desktop (ui/src/mascot/useRelicState.ts). */
38export const MOOD_WORDS: Record<Mood, string> = {
39 idle: 'Standing vigil',
40 listening: 'Listening',
41 thinking: 'Deliberating',
42 hacking: 'Executing',
43 awaiting: 'Awaiting your word',
44 victory: 'Victory',
45 error: 'Fault detected',
46 sleeping: 'Dormant',
47 annoyed: 'Annoyed',
48}
49
50/** Moods that switch at once; every other mood dwells at least MOOD_DWELL_MS (desktop mascot rule). */
51export const URGENT_MOODS: ReadonlySet<Mood> = new Set(['awaiting', 'error'])
52export const MOOD_DWELL_MS = 1800
53/** How long Victory and Fault show before the agent rests (desktop ui/src/mascot/useBustState.ts VICTORY_MS, ERROR_MS). */
54export const TRANSIENT_MOOD_MS: Partial<Record<Mood, number>> = { victory: 3200, error: 5000 }
55
56/** One-cell marks. Each must be width 1 in Windows Terminal (spike S8 measures them; the fallbacks are ASCII-safe). */
57export const MARK = {
58 running: '●',
59 queued: '◐',
60 done: '·',
61 error: '✕',
62 paused: '‖',
63 cancelled: '–',
64 card: '!',
65 arrow: '▸',
66 bar: '│',
67 rule: '─',
68 ellipsis: '…',
69 caret: '▍',
70} as const
71
72/** The width of the rail column, in cells, at each pane width. */
73export const railColumns = (bodyColumns: number): number => (bodyColumns >= 100 ? 19 : bodyColumns >= 72 ? 18 : 0)
74
75/** Breakpoints the views lay out against, in cells. */
76export const BREAKPOINTS = { narrow: 72, medium: 100, wide: 140 } as const
77src/ui/band.ts 113 lines1/**
2 * The band above the prompt: only what needs the person, one row each, at most three, then `+N more · /legion to open`.
3 * Plan §4 "Band". It yields to a survey and is gone when nothing waits (the render hook passes then).
4 *
5 * Rows, in order: the open channel (`Talking to ✠ Zealot`), what needs your OK (oldest first), then the items the runtime
6 * posted (done, paused, failed tasks, Library notes), newest first. Each row's words are built here from its kind and
7 * its task, so the agent is named once and the state once (review bug 1).
8 *
9 * Keys come from the one key map (claude/tui-information-design.md "Keys"): the first paused task takes `c` (not one
10 * another window owns); open and dismiss are reached with Tab. No digits: a bare digit typed into an empty prompt
11 * presses a band Button (ButtonProps.hotkey). A "needs your OK" row carries no control: it is answered in Claude Code's
12 * own permission dialog (plan §2.1). It comes from the `cards` state; a band item of kind `card` is not drawn twice.
13 */
14import type { ApprovalCard, BandItem } from '../../types/index.d.ts'
15import { MARK } from '../theme.ts'
16import { count } from './format.ts'
17import { bandOrder } from './actions.ts'
18import { btn, line, partsWidth, span, uniqueKeys, type Part, type Row, type SpanTone } from './model.ts'
19import { oneLine } from './text.ts'
20import { answerWhere, needsHead } from './cards.ts'
21import { agentById, agentLabel, cardsInOrder, cleanTitle, isElsewhere, taskOf, type Snapshot } from './views/common.ts'
22import { NEEDS_YOU, STATE_WORD } from './words.ts'
23
24export const BAND_MAX = 3
25
26/** The fewest cells a band row's title keeps before the dismiss control may take room from it. */
27const TITLE_MIN = 12
28
29type Entry =
30 | { kind: 'channel'; agentId: string }
31 | { kind: 'card'; card: ApprovalCard; index: number }
32 | { kind: 'item'; item: BandItem; index: number }
33
34const ITEM_MARK: Record<BandItem['kind'], { mark: string; tone: SpanTone }> = {
35 card: { mark: MARK.card, tone: 'warn' },
36 done: { mark: MARK.done, tone: 'muted' },
37 paused: { mark: MARK.paused, tone: 'warn' },
38 error: { mark: MARK.error, tone: 'danger' },
39 inbox: { mark: MARK.done, tone: 'muted' },
40}
41
42/** Everything that waits, in band order. */
43export const bandEntries = (s: Snapshot): Entry[] => [
44 ...(s.ui.channel ? [{ kind: 'channel', agentId: s.ui.channel } as const] : []),
45 ...cardsInOrder(s).map((card, index) => ({ kind: 'card', card, index }) as const),
46 ...bandOrder(s.band).map((item, index) => ({ kind: 'item', item, index }) as const),
47]
48
49/** The words a band item says after the agent: built from its kind and its task, never from the runtime's text (bug 1). */
50const itemWords = (s: Snapshot, it: BandItem): { title: string; state: string } => {
51 const task = it.taskId ? taskOf(s, it.taskId) : undefined
52 // the runtime writes "<title> · <state>"; without the task, its title part is the best we have
53 const title = task ? cleanTitle(task.title) : oneLine(it.text).split(' · ')[0] ?? ''
54 if (it.kind === 'paused') return { title, state: STATE_WORD.paused }
55 if (it.kind === 'done') return { title, state: STATE_WORD.done }
56 if (it.kind === 'error') return { title, state: `${STATE_WORD.error}${task?.error ? `: ${oneLine(task.error)}` : ''}` }
57 return { title: oneLine(it.text), state: '' }
58}
59
60/** The band's rows at `width` cells and at most `maxRows` rows; none when nothing waits. */
61export const bandRows = (s: Snapshot, width: number, maxRows: number): Row[] => {
62 const all = bandEntries(s)
63 const cap = Math.max(0, Math.min(BAND_MAX, Math.floor(maxRows)))
64 if (all.length === 0 || cap === 0) return []
65 const shown = all.length <= cap ? all : all.slice(0, cap - 1)
66 const taken = new Set<string>()
67 /** The hotkey once per band: the first row that asks for it gets it. */
68 const key = (k: string): string | undefined => (taken.has(k) ? undefined : (taken.add(k), k))
69 const rows = shown.map((e): Row => {
70 if (e.kind === 'channel') {
71 // C2: every message goes to the agent while this shows
72 return line([span(' '), span('Talking to ', 'accent', { fixed: true }), span(agentLabel(s, e.agentId), 'accent', { bold: true, fixed: true }), span(' · /legion talk off', 'muted', { fixed: true })], [], width)
73 }
74 if (e.kind === 'card') {
75 // who, needs your OK, what for when it fits whole, the exact call, then where to answer (polish 8: no cut verb)
76 const c = e.card
77 const where: Part[] = [span(answerWhere(isElsewhere(s, taskOf(s, c.taskId)), true), 'muted', { fixed: true }), span(' ')]
78 const head = (full: boolean): Part[] => [span(' '), span(`${MARK.card} `, 'warn', { fixed: true }), span(agentLabel(s, c.agentId), 'text', { bold: true, fixed: true }), span(` ${full ? needsHead(c.tool) : NEEDS_YOU}`, 'warn', { fixed: true }), span(' · ', 'muted', { fixed: true })]
79 const full = partsWidth([...head(true), ...where]) + 20 + 2 <= width
80 return line([...head(full), span(oneLine(c.summary))], where, width, 2)
81 }
82 const it = e.item
83 const m = ITEM_MARK[it.kind]
84 const w = itemWords(s, it)
85 const left: Part[] = [span(' '), span(`${m.mark} `, m.tone, { fixed: true })]
86 // below 56 cells the agent is its glyph, so the state and its key keep their room
87 if (it.agentId) left.push(span(width >= 56 ? agentLabel(s, it.agentId) : agentById(s, it.agentId)?.glyph ?? '', 'text', { bold: true, fixed: true }), span(' · ', 'muted', { fixed: true }))
88 left.push(span(w.title))
89 // the state word is fixed; a failure's reason is what gives way
90 const [state, ...reason] = w.state.split(': ')
91 if (state) left.push(span(` · ${state}`, it.kind === 'error' ? 'danger' : it.kind === 'paused' ? 'warn' : 'muted', { fixed: true }))
92 if (reason.length > 0) left.push(span(`: ${reason.join(': ')}`, 'danger'))
93 const right: Part[] = []
94 if (it.kind === 'paused' && it.taskId && !isElsewhere(s, taskOf(s, it.taskId))) {
95 right.push(btn({ kind: 'continue', taskId: it.taskId }, 'continue', { hotkey: key('c') }), span(' '))
96 } else if ((it.kind === 'done' || it.kind === 'error') && it.taskId) {
97 right.push(btn({ kind: 'task', taskId: it.taskId }, 'open'), span(' '))
98 }
99 // dismiss leaves first when the row is short: the row's own action stays
100 const dismiss: Part[] = [btn({ kind: 'band-dismiss', itemId: it.id, index: e.index }, 'dismiss', { dim: true }), span(' ')]
101 const minLeft = partsWidth(left.filter(p => p.t === 'button' || (p.t === 'text' && p.fixed)))
102 // the title keeps at least TITLE_MIN cells before dismiss may take room
103 if (minLeft + TITLE_MIN + partsWidth([...right, ...dismiss]) + 2 <= width) right.push(...dismiss)
104 else if (right.length > 0) right.splice(right.length - 1, 1, span(' '))
105 return line(left, right, width, 2)
106 })
107 if (shown.length < all.length) {
108 const more = all.length - shown.length
109 rows.push(line([span(' '), span(`${count(more)} more · /legion to open`, 'muted')], [], width))
110 }
111 return uniqueKeys(rows)
112}
113src/ui/components.tsx 104 lines1/**
2 * Rows to elements: the one place the Legion UI makes the surface's elements. Pure drawing helpers: each takes the
3 * element table `$.ui.resolve(e)` answered, the palette (theme.ts) and rows, never `$`, so they run under any hook.
4 *
5 * A `line` row is spans and plain Buttons side by side, already exactly as wide as its box (model.ts), so the
6 * terminal neither wraps nor truncates it. An `md` row is a lead column beside a Markdown block the surface lays out.
7 */
8import type { ElementTable, RenderElement } from 'claude-code'
9import type { Palette } from '../theme.ts'
10import { MARK } from '../theme.ts'
11import type { Part, Row, SpanTone } from './model.ts'
12import type { PaneLayout } from './views/pane.ts'
13
14/** The elements every surface has that the Legion UI draws with. */
15export type El = Pick<ElementTable, 'Box' | 'Text' | 'Button' | 'Markdown'>
16
17/** A colour for a tone; `undefined` keeps the terminal's own (text) or draws dim (muted), which adapts to any theme. */
18export const toneColor = (pal: Palette, tone: SpanTone | undefined): string | undefined => {
19 switch (tone) {
20 case 'accent': return pal.accentText
21 case 'warn': return pal.warn
22 case 'danger': return pal.danger
23 case 'line': return pal.lineStrong
24 default: return undefined
25 }
26}
27
28/** Markdown takes at most 10000 characters, tab and newline its only control characters (MarkdownProps.text). */
29export const MARKDOWN_MAX = 10000
30export const markdownSafe = (text: string): string => {
31 const clean = text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
32 // keep the end: a streaming reply's newest words are the ones to see
33 return clean.length > MARKDOWN_MAX ? `${MARK.ellipsis}${clean.slice(clean.length - (MARKDOWN_MAX - 1))}` : clean
34}
35
36/**
37 * A Legion Button's own `onPress` does nothing: the press raises `ui.press` with the Button's key, and the lead's hook
38 * acts on it (actions.ts). ButtonProps requires the handler.
39 */
40const pressedElsewhere = (): void => {}
41
42export const PartEl = (el: El, pal: Palette, p: Part): RenderElement => {
43 const { Text, Button } = el
44 if (p.t === 'button') {
45 return <Button key={p.key} label={p.label} plain hotkey={p.hotkey} dimColor={p.dim} onPress={pressedElsewhere} />
46 }
47 return (
48 <Text color={toneColor(pal, p.tone)} dimColor={p.tone === 'muted' ? true : undefined} bold={p.bold ? true : undefined} strikethrough={p.strike ? true : undefined}>
49 {p.text}
50 </Text>
51 )
52}
53
54export const Line = (el: El, pal: Palette, parts: readonly Part[]): RenderElement => {
55 const { Box } = el
56 return <Box flexDirection="row">{parts.map(p => PartEl(el, pal, p))}</Box>
57}
58
59export const Rule = (el: El, pal: Palette, width: number): RenderElement => {
60 const { Text } = el
61 return <Text color={pal.lineStrong}>{MARK.rule.repeat(Math.max(0, width))}</Text>
62}
63
64export const RowEl = (el: El, pal: Palette, r: Row): RenderElement => {
65 const { Box, Text, Markdown } = el
66 if (r.t === 'gap') return <Text> </Text>
67 if (r.t === 'line') return Line(el, pal, r.parts)
68 return (
69 <Box flexDirection="row" width={r.width}>
70 <Box width={r.leadWidth} flexShrink={0}>{Line(el, pal, r.lead)}</Box>
71 <Box flexGrow={1} flexDirection="column"><Markdown text={markdownSafe(r.text)} dimColor={r.dim ? true : undefined} /></Box>
72 </Box>
73 )
74}
75
76export const Rows = (el: El, pal: Palette, rows: readonly Row[]): RenderElement => {
77 const { Box } = el
78 return <Box flexDirection="column">{rows.map(r => RowEl(el, pal, r))}</Box>
79}
80
81/**
82 * The pane: title and rule, then the rail beside the view when there is one. The rail's separator runs as far as the
83 * view's estimated height, so the column reads as one even when the thread is long.
84 */
85export const Pane = (el: El, pal: Palette, layout: PaneLayout): RenderElement => {
86 const { Box, Text } = el
87 if (layout.railWidth === 0) return Rows(el, pal, [...layout.title, ...layout.main])
88 const mainHeight = layout.main.reduce((n, r) => n + (r.t === 'md' ? Math.max(1, r.estRows) : 1), 0)
89 const pad = Math.max(0, mainHeight - layout.rail.length)
90 const blank = ' '.repeat(layout.railWidth - 1)
91 return (
92 <Box flexDirection="column">
93 {layout.title.map(r => RowEl(el, pal, r))}
94 <Box flexDirection="row">
95 <Box flexDirection="column" width={layout.railWidth} flexShrink={0}>
96 {layout.rail.map(r => RowEl(el, pal, r))}
97 {Array.from({ length: pad }, () => <Box flexDirection="row"><Text>{blank}</Text><Text color={pal.lineStrong}>{MARK.bar}</Text></Box>)}
98 </Box>
99 <Box flexDirection="column" flexGrow={1}>{layout.main.map(r => RowEl(el, pal, r))}</Box>
100 </Box>
101 </Box>
102 )
103}
104src/ui/dispatch.ts 75 lines1/**
2 * The transcript card a `/to <agent> <text>` or `/say <text>` leaves (plan §4 "Transcript dispatch cards"): live while
3 * the task works, then frozen into one summary line, `· ⌘ Builder · done · ≈$0.21 · 9 turns`.
4 *
5 * Contract with the command (lead-owned): the command's output text names the task as `t_` + 12 hex (TaskView.id).
6 * With no such id, or a task the state does not hold, the card is not drawn and the engine draws the text.
7 *
8 * The card holds no controls: a waiting approval is answered in Claude Code's own permission dialog (plan §2.1).
9 */
10import type { TaskView } from '../../types/index.d.ts'
11import { MARK } from '../theme.ts'
12import { money, plural } from './format.ts'
13import { line, span, uniqueKeys, wrapped, type Part, type Row } from './model.ts'
14import { cellWidth, oneLine } from './text.ts'
15import { answerWhere } from './cards.ts'
16import { agentLabel, cardsInOrder, isElsewhere, cleanTitle, isActive, nowOf, STATUS_MARK, STATUS_WORDS, type Snapshot } from './views/common.ts'
17import { NEEDS_YOU, STATE_WORD } from './words.ts'
18
19const TASK_ID = /\bt_[0-9a-f]{12}\b/
20
21/** The task id a command's output names, or none. */
22export const taskIdIn = (text: string): string | undefined => TASK_ID.exec(text)?.[0]
23
24/** The numbers of a summary line, as every other surface orders them: cost, then turns; a zero says nothing (R4). */
25const tally = (t: TaskView): string => [t.costUsd >= 0.01 ? money(t.costUsd) : '', t.turns > 0 ? plural(t.turns, 'turn') : ''].filter(Boolean).map(x => ` · ${x}`).join('')
26
27/**
28 * The rows of the card for `task`, or none when the text names no task the state holds. Every line starts with the
29 * task's state mark, names the agent once, and gives the next step as a command that names its task's agent (review
30 * bug 2: a bare /legion continue acts on the task the pane shows).
31 */
32export const dispatchRows = (s: Snapshot, text: string, width: number): Row[] | undefined => {
33 const id = taskIdIn(text)
34 const task = id ? s.tasks.find(t => t.id === id) : undefined
35 if (!task) return undefined
36 const who = agentLabel(s, task.agentId)
37 const st = STATUS_MARK[task.status]
38 const head: Part[] = [span(`${st.mark} `, st.tone, { fixed: true }), span(who, 'text', { bold: true, fixed: true })]
39 if (!isActive(task)) {
40 // frozen: one line; a paused task says how to go on, a failed one what went wrong and what to do
41 // the numbers give way first on a narrow line, then the long state words; the state and the next step stay
42 const next = task.status === 'paused' && !isElsewhere(s, task) ? ` · /legion continue ${task.agentId}` : ''
43 const full = STATUS_WORDS[task.status]
44 const fits = (words: string, numbers: string) => cellWidth(`${st.mark} ${who} · ${words}${numbers}${next}`) <= width
45 const numbers = fits(full, tally(task)) ? tally(task) : ''
46 const words = task.status === 'paused' && !fits(full, numbers) ? STATE_WORD.paused : full
47 // narrower still, the command already names the agent, so the label goes: `‖ paused · /legion continue builder`
48 const label = fits(words, numbers) || !next ? [...head, span(' · ', 'muted', { fixed: true })] : [span(`${st.mark} `, st.tone, { fixed: true })]
49 const left: Part[] = [...label, span(words, st.tone === 'muted' ? 'text' : st.tone, { fixed: true }), span(numbers, 'muted')]
50 if (next) left.push(span(next, 'accent', { fixed: true }))
51 const rows = [line(left, [], width)]
52 if (task.status === 'error') {
53 rows.push(...wrapped(task.error || 'No reason was given.', width, { indent: 2, tone: 'muted', maxLines: 2 }))
54 rows.push(line([span(' '), span(`/to ${task.agentId} <what to do> to try again`, 'muted', { fixed: true })], [], width))
55 }
56 return rows
57 }
58 // live: who and what it is doing now (R2), the numbers; the task's title; what needs your OK, if anything
59 // the card line says "needs your OK" when there is one, so the first line then leaves its verb out (R1)
60 const card = cardsInOrder(s).find(c => c.taskId === task.id)
61 const rows: Row[] = [
62 line([...head, ...(card ? [] : [span(' · ', 'muted', { fixed: true }), span(nowOf(s, task, 30), 'text')]), span(tally(task), 'muted', { fixed: true })], [], width),
63 line([span(' '), span(oneLine(cleanTitle(task.title)), 'muted')], [], width),
64 ]
65 if (card) {
66 // where to answer stays on a wide line; below 56 cells the command has the room
67 const where: Part[] = width >= 56 ? [span(answerWhere(isElsewhere(s, task), true), 'muted', { fixed: true }), span(' ')] : []
68 rows.push(line(
69 [span(` ${MARK.card} `, 'warn', { bold: true, fixed: true }), span(NEEDS_YOU, 'warn', { bold: true, fixed: true }), span(' · ', 'muted', { fixed: true }), span(oneLine(card.summary))],
70 where, width, 2,
71 ))
72 }
73 return uniqueKeys(rows)
74}
75src/ui/status.ts 54 lines1/**
2 * The status line under the prompt (`$.ui.status`): plain text, at most 80 cells. Plan §4 "Status line".
3 *
4 * ⌘ Builder · editing replay.test.ts · 1 needs your OK · 2 working
5 * Talking to ✠ Zealot · /legion talk off (while a channel is open)
6 *
7 * `$.ui.status` draws plain text, so the plan's "turns accent while a channel is open" cannot be drawn here; the
8 * band carries the channel in accent instead (band.ts).
9 */
10import { count } from './format.ts'
11import { cellWidth, cutCells } from './text.ts'
12import { agentLabel, nowOf, selectedAgent, type Snapshot } from './views/common.ts'
13import { counts, NEEDS_YOU } from './words.ts'
14
15export const STATUS_MAX = 80
16
17/**
18 * Joins parts with ' · '. While the line is too long, parts leave in `dropOrder` (indexes into `parts`); what needs the
19 * person goes last. A line still too long is cut with '…'.
20 */
21const fitParts = (parts: string[], max: number, dropOrder: number[] = []): string => {
22 const gone = new Set<number>()
23 const join = (): string => parts.filter((_, i) => !gone.has(i)).join(' · ')
24 for (const i of dropOrder) {
25 if (cellWidth(join()) <= max) break
26 gone.add(i)
27 }
28 return cutCells(join(), max)
29}
30
31/**
32 * The status line's text; `undefined` (clear the line) when there are no agents to speak of. The shown agent and what
33 * its task is doing now (R2, a verb, never a mood word), then the counts, what needs your OK first (R11 words; the
34 * same count function as the title row, so the two agree).
35 */
36export const statusText = (s: Snapshot, max = STATUS_MAX): string | undefined => {
37 if (s.ui.channel) return fitParts([`Talking to ${agentLabel(s, s.ui.channel)}`, '/legion talk off'], max, [1])
38 const agent = selectedAgent(s)
39 if (!agent) return undefined
40 const c = counts(s.tasks, s.cards)
41 const mine = s.tasks.filter(t => t.agentId === agent.id && t.status === 'running').sort((a, b) => b.updatedAt - a.updatedAt)[0]
42 const parts = [`${agent.glyph} ${agent.name}`]
43 const drop: number[] = []
44 // a verb, unless it would only repeat the count of what needs your OK beside it (R1)
45 const verb = mine ? nowOf(s, mine, 30) : undefined
46 if (verb && verb !== NEEDS_YOU) { drop.push(parts.length); parts.push(verb) }
47 if (c.needsYou > 0) parts.push(`${count(c.needsYou)} ${c.needsYou === 1 ? 'needs' : 'need'} your OK`)
48 const tail: number[] = []
49 if (c.working > 0) { tail.push(parts.length); parts.push(`${count(c.working)} working`) }
50 if (c.paused > 0) { tail.push(parts.length); parts.push(`${count(c.paused)} paused`) }
51 // the paused and working counts leave first, then the verb; the agent and what needs your OK stay
52 return fitParts(parts, max, [...tail.reverse(), ...drop])
53}
54src/ui/views/common.ts 168 lines1/**
2 * What every Legion view reads, and the small rules they share: which agents show, which is selected, how an agent
3 * and a task are named, the marks and words for each state. Pure.
4 */
5import type { AgentId, AgentView, ApprovalCard, ApprovalMode, BandItem, DoctorLine, MoodState, ModSettings, TaskStatus, TaskView, ThreadRow, UiState } from '../../../types/index.d.ts'
6import { MARK, MOOD_WORDS } from '../../theme.ts'
7import type { SpanTone } from '../model.ts'
8import { cardsInOrder as orderCards } from '../actions.ts'
9import { currentTool, nowVerb, STATE_WORD } from '../words.ts'
10import { cellWidth } from '../text.ts'
11
12/** One read of the mod's state, as the render hooks gather it (register-ui.tsx). */
13export type Snapshot = {
14 agents: readonly AgentView[]
15 tasks: readonly TaskView[]
16 cards: readonly ApprovalCard[]
17 ui: UiState
18 moods: Readonly<Record<AgentId, MoodState>>
19 band: readonly BandItem[]
20 settings?: ModSettings
21 /** The selected task's rows (the `threads` family member), or none. */
22 thread: readonly ThreadRow[]
23 /** The selected task's streaming reply (the `live` family member), or ''. */
24 live: string
25 /** The clock, for "5m ago". */
26 now: number
27 /** The last `/legion doctor` run, or none yet. */
28 doctor?: readonly DoctorLine[]
29 /** This window's session id. A task another session owns is shown read-only; unknown ('') counts every task as ours. */
30 sessionId?: string
31 /** Rows of the other tasks the pane draws (their current tool gives their verb), keyed by task id. */
32 threads?: Readonly<Record<string, readonly ThreadRow[]>>
33}
34
35export const DEFAULT_UI: UiState = { view: 'chat', agentId: 'zealot', taskId: null, channel: null }
36
37/** Agents a person may pick: hidden ones (the Assayer needs BSV, the Sculptor Blender; the mod leaves both out) are not offered. */
38export const visibleAgents = (s: Pick<Snapshot, 'agents'>): AgentView[] => s.agents.filter(a => !a.isHidden)
39
40/** The agent in view: the one `ui.agentId` names, else the first visible one, else none. */
41export const selectedAgent = (s: Pick<Snapshot, 'agents' | 'ui'>): AgentView | undefined => {
42 const shown = visibleAgents(s)
43 return shown.find(a => a.id === s.ui.agentId) ?? shown[0]
44}
45
46/** The agent with this id, visible or not (a hidden agent's old task still names it). */
47export const agentById = (s: Pick<Snapshot, 'agents'>, id: AgentId | undefined): AgentView | undefined => s.agents.find(a => a.id === id)
48
49/** `⌘ Builder`, or the bare id with a neutral dot when the agent is gone. */
50export const agentLabel = (s: Pick<Snapshot, 'agents'>, id: AgentId | undefined): string => {
51 const a = agentById(s, id)
52 return a ? `${a.glyph} ${a.name}` : `${MARK.running} ${id ?? 'agent'}`
53}
54
55export const glyphOf = (s: Pick<Snapshot, 'agents'>, id: AgentId | undefined): string => agentById(s, id)?.glyph ?? MARK.running
56
57/** The approval mode in the desktop's words (ui/src/components/Thread.tsx, the approval chip). */
58export const APPROVAL_WORDS: Record<ApprovalMode, string> = { ask: 'Asks first', 'auto-edits': 'Auto-edits', full: 'Full access' }
59
60export const isActive = (t: Pick<TaskView, 'status'>): boolean => t.status === 'running' || t.status === 'queued'
61
62/** True when another window owns the task's run: this window shows it, and cannot continue, stop or answer for it. */
63export const isElsewhere = (s: Pick<Snapshot, 'sessionId'>, t: Pick<TaskView, 'sessionId'> | undefined): boolean =>
64 !!t && !!s.sessionId && !!t.sessionId && t.sessionId !== s.sessionId
65
66/** The task a card belongs to. */
67export const taskOf = (s: Pick<Snapshot, 'tasks'>, taskId: string): TaskView | undefined => s.tasks.find(t => t.id === taskId)
68
69/** The model as the header says it: `auto` is the router's choice, said so. */
70export const modelWords = (model: string): string => (model === 'auto' ? 'auto model' : model)
71
72/** The one-cell mark and its tone for a task's state (theme.ts MARK). */
73export const STATUS_MARK: Record<TaskStatus, { mark: string; tone: SpanTone }> = {
74 running: { mark: MARK.running, tone: 'accent' },
75 // accent marks only what is live (R9); queued waits, so it is quiet
76 queued: { mark: MARK.queued, tone: 'muted' },
77 paused: { mark: MARK.paused, tone: 'warn' },
78 error: { mark: MARK.error, tone: 'danger' },
79 cancelled: { mark: MARK.cancelled, tone: 'muted' },
80 done: { mark: MARK.done, tone: 'muted' },
81}
82
83/** A task's state in the glossary's words (words.ts STATE_WORD), with the paused state's reason, for one-line summaries. */
84export const STATUS_WORDS: Record<TaskStatus, string> = { ...STATE_WORD, paused: 'paused at the turn limit' }
85
86/** The title as shown: the router prefix (/opus, /sonnet) dropped, as the desktop's cleanTitle (ui/src/util.ts:63). */
87export const cleanTitle = (t: string | undefined): string => (t ?? '').replace(/^\s*\/(opus|sonnet)\b\s*/i, '').trim() || 'Untitled'
88
89/** An agent's mood word, exactly as MOOD_WORDS has it; no mood recorded reads as idle ("Standing vigil"). */
90export const moodWord = (s: Pick<Snapshot, 'moods'>, id: AgentId): string => MOOD_WORDS[s.moods[id]?.mood ?? 'idle']
91
92/** The tone a mood is drawn in: accent while working, warn while it needs you, danger on a fault, muted at rest. */
93export const moodTone = (s: Pick<Snapshot, 'moods'>, id: AgentId): SpanTone => {
94 const m = s.moods[id]?.mood ?? 'idle'
95 if (m === 'awaiting') return 'warn'
96 if (m === 'error') return 'danger'
97 if (m === 'listening' || m === 'thinking' || m === 'hacking' || m === 'victory') return 'accent'
98 return 'muted'
99}
100
101/** The tasks of one agent, newest created first (stable: tabs never jump when a task finishes; TaskSwitcher.tsx). */
102export const tasksOf = (s: Pick<Snapshot, 'tasks'>, agentId: AgentId): TaskView[] =>
103 s.tasks.filter(t => t.agentId === agentId).sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id))
104
105/** The task in view: `ui.taskId` when it is the selected agent's, else none (a new-task screen). */
106export const selectedTask = (s: Pick<Snapshot, 'tasks' | 'ui' | 'agents'>): TaskView | undefined => {
107 const agent = selectedAgent(s)
108 const t = s.ui.taskId ? s.tasks.find(x => x.id === s.ui.taskId) : undefined
109 return t && agent && t.agentId === agent.id ? t : undefined
110}
111
112/** Cards in the order they arrived (actions.ts): the first is the one `a` and `d` answer; a card's index here is its `allow#n`. */
113export const cardsInOrder = (s: Pick<Snapshot, 'cards'>): ApprovalCard[] => orderCards(s.cards)
114
115/**
116 * Who started a task, in words, or nothing for the person's own: "from ✠ Zealot" (bridge), "from a room",
117 * "from Claude Code" (Claude Code's own model delegated to a Legion agent).
118 */
119export const originWords = (s: Pick<Snapshot, 'agents'>, t: Pick<TaskView, 'origin'>): string | undefined => {
120 switch (t.origin.kind) {
121 case 'person': return undefined
122 case 'bridge': return `from ${agentLabel(s, t.origin.fromAgentId)}`
123 case 'room': return 'from a room'
124 case 'claude-code': return 'from Claude Code'
125 }
126}
127
128/** A tool's short name, as the desktop's shortTool (ui/src/util.ts:18): `mcp__legion-mod__kg_search` -> `legion-mod·kg_search`. */
129export const shortTool = (name: string | undefined): string => (name ?? 'tool').replace(/^mcp__/, '').replace(/__/g, '·')
130
131/**
132 * The view flags the pane draws from that the contract does not hold yet: the key-help view and a task whose folded
133 * steps are open. Read defensively, so the layout is testable before the lead's `UiState.keysOpen` / `stepsOpen` land.
134 */
135export const uiFlags = (ui: UiState): { keysOpen: boolean; stepsOpen: string | null } => {
136 const extra = ui as UiState & { keysOpen?: unknown; stepsOpen?: unknown }
137 return { keysOpen: extra.keysOpen === true, stepsOpen: typeof extra.stepsOpen === 'string' ? extra.stepsOpen : null }
138}
139
140/** A task's rows, where the snapshot holds them: the selected task's thread, or a drawn task's from `threads`. */
141export const threadOf = (s: Pick<Snapshot, 'thread' | 'threads' | 'ui'>, taskId: string): readonly ThreadRow[] | undefined =>
142 s.threads?.[taskId] ?? (s.ui.taskId === taskId ? s.thread : undefined)
143
144/**
145 * What a task is doing now, in the glossary's words (R2, R11): a working task's verb from its current tool, else its
146 * state word; another window's task says so. A mood word never stands in for it.
147 */
148export const nowOf = (s: Snapshot, t: TaskView, max = 28): string => {
149 if (isElsewhere(s, t)) return 'in another window'
150 if (t.status !== 'running') return STATE_WORD[t.status]
151 const hasCard = s.cards.some(c => c.taskId === t.id)
152 const tool = currentTool(threadOf(s, t.id))
153 // between its own steps, a task whose agents still work is waiting on them: say who (glossary: "handed to")
154 if (!tool && !hasCard) {
155 const open = s.tasks.filter(c => c.origin.kind === 'bridge' && c.origin.fromTaskId === t.id && (c.status === 'running' || c.status === 'queued'))
156 if (open.length > 0) {
157 const first = s.agents.find(a => a.id === open[0]!.agentId)
158 const full = `handed to ${first ? `${first.glyph} ${first.name}` : open[0]!.agentId}${open.length > 1 ? ` +${open.length - 1}` : ''}`
159 return cellWidth(full) <= max ? full : `handed to ${open.length}`
160 }
161 }
162 return nowVerb(tool, hasCard, name => agentByName(s, name)?.glyph ?? MARK.running, max)
163}
164
165/** The agent with this display name (the wire writes names, not ids, into "Ask Scout: …"). */
166export const agentByName = (s: Pick<Snapshot, 'agents'>, name: string): AgentView | undefined =>
167 s.agents.find(a => a.name.toLowerCase() === name.trim().toLowerCase() || a.id === name.trim().toLowerCase())
168src/ui/views/pane.ts 86 lines1/**
2 * The Legion pane as rows: the title row (wordmark, view tabs, the counts), a rule, then the view, or the key-help
3 * view while it is open. Phase 1 draws two views, Chat and Order; Rooms, Library and Board get no tab and no placeholder.
4 */
5import type { ViewId } from '../../../types/index.d.ts'
6import { MARK } from '../../theme.ts'
7import { count } from '../format.ts'
8import { btn, line, partsWidth, span, uniqueKeys, type Part, type Row, type SpanTone } from '../model.ts'
9import { cellWidth } from '../text.ts'
10import { counts } from '../words.ts'
11import { chatLayout, keysRows, type ChatLayout } from './chat.ts'
12import { uiFlags, type Snapshot } from './common.ts'
13import { orderRows } from './order.ts'
14
15/** The views this phase draws, numbered by what exists (R6: renumbered when Rooms and the Library arrive). */
16export const VIEWS: ReadonlyArray<{ id: ViewId; key: string; label: string }> = [
17 { id: 'chat', key: '1', label: 'Chat' },
18 { id: 'order', key: '2', label: 'Order' },
19]
20
21/** Rows above the view: the title row and its rule. */
22export const CHROME_ROWS = 2
23
24/** A view this phase does not draw shows Chat. */
25export const shownView = (v: ViewId | undefined): ViewId => (VIEWS.some(x => x.id === v) ? (v as ViewId) : 'chat')
26
27/**
28 * The counts the title row says, in reading order: what needs the person first (R1: this is the one place on screen
29 * for them). Each part has the rank in which it leaves a short row; "needs your OK" leaves last, and below that it
30 * shortens to `!1`.
31 */
32const countParts = (s: Snapshot): Array<{ text: string; tone: SpanTone; drop: number }> => {
33 const c = counts(s.tasks, s.cards)
34 const parts: Array<{ text: string; tone: SpanTone; drop: number }> = []
35 if (c.needsYou > 0) parts.push({ text: `${count(c.needsYou)} ${c.needsYou === 1 ? 'needs' : 'need'} your OK`, tone: 'warn', drop: 4 })
36 if (c.working > 0) parts.push({ text: `${count(c.working)} working`, tone: 'muted', drop: 3 })
37 if (c.paused > 0) parts.push({ text: `${count(c.paused)} paused`, tone: 'warn', drop: 2 })
38 if (c.queued > 0) parts.push({ text: `${count(c.queued)} queued`, tone: 'muted', drop: 1 })
39 return parts
40}
41
42/**
43 * ` ✠ LEGION 1: Chat 2: Order 1 needs your OK · 3 working`. The active tab is text in accent; the other
44 * is a Button with its digit, the same width, so nothing moves when the view changes. Spacers are fixed: the tabs never
45 * run together (review bug 3).
46 */
47export const titleRow = (s: Snapshot, width: number): Row => {
48 const view = shownView(s.ui.view)
49 const left: Part[] = [span(' ', 'text', { fixed: true }), span('✠ LEGION', 'accent', { bold: true, fixed: true }), span(' ', 'text', { fixed: true })]
50 for (const v of VIEWS) {
51 left.push(span(' ', 'text', { fixed: true }))
52 left.push(v.id === view ? span(`${v.key}: ${v.label}`, 'text', { bold: true, fixed: true }) : btn({ kind: 'view', view: v.id }, v.label, { hotkey: v.key, dim: true }))
53 left.push(span(' ', 'text', { fixed: true }))
54 }
55 const room = width - partsWidth(left) - 2
56 let parts = countParts(s)
57 const text = (ps: typeof parts): string => ps.map(p => p.text).join(' · ')
58 for (const rank of [1, 2, 3]) if (cellWidth(text(parts)) > room) parts = parts.filter(p => p.drop !== rank)
59 const needs = counts(s.tasks, s.cards).needsYou
60 if (cellWidth(text(parts)) > room && needs > 0) parts = [{ text: `${MARK.card}${count(needs)}`, tone: 'warn', drop: 4 }]
61 const right: Part[] = []
62 parts.forEach((p, i) => {
63 if (i > 0) right.push(span(' · ', 'muted', { fixed: true }))
64 right.push(span(p.text, p.tone, { fixed: true }))
65 })
66 if (right.length > 0) right.push(span(' ', 'text', { fixed: true }))
67 return line(left, right, width, 2)
68}
69
70export const ruleRow = (width: number): Row => line([span(MARK.rule.repeat(Math.max(0, width)), 'line')], [], width)
71
72export type PaneLayout = { title: Row[] } & ChatLayout
73
74/** The whole pane at `width` cells, its body fitted to `bodyRows` rows. Order has no rail. */
75export const paneLayout = (s: Snapshot, width: number, bodyRows: number): PaneLayout => {
76 const title = [titleRow(s, width), ruleRow(width)]
77 const l: PaneLayout = uiFlags(s.ui).keysOpen
78 ? { title, railWidth: 0, rail: [], main: keysRows(width) }
79 : shownView(s.ui.view) === 'order'
80 ? { title, railWidth: 0, rail: [], main: orderRows(s, width) }
81 : { title, ...chatLayout(s, width, bodyRows, CHROME_ROWS) }
82 // the pane is one site: a key (a task opened from its tab and from a card notice) is unique across all of it
83 const all = uniqueKeys([...l.title, ...l.rail, ...l.main])
84 return { title: all.slice(0, l.title.length), railWidth: l.railWidth, rail: all.slice(l.title.length, l.title.length + l.rail.length), main: all.slice(l.title.length + l.rail.length) }
85}
86src/engine/bridge.ts 159 lines1/**
2 * The agent bridge's guards and wording: ask (wait for the answer), tell (the answer arrives later), `agents` (who is there).
3 * Ported from desktop src/core/bridge.ts; pure. In the mod, ask and tell are Claude Code's own Agent tool with
4 * `subagent_type: "legion-mod:<agent>"` (run_in_background false = ask, true = tell; plan §1). The lead's hook judges each such
5 * call with checkAsk; the queueing, waiting and reply delivery live in the lead's wiring.
6 */
7import type { AgentId, AgentView, TaskView } from '../../types/index.d.ts'
8import { agentTypeName } from './prompt.ts'
9import { AGENT_TYPE_PREFIX } from './tool-names.ts'
10
11/** Desktop src/core/bridge.ts:9-13, unchanged. */
12export const MAX_DEPTH = 3
13export const MAX_HOP = 6
14export const RESULT_MAX_CHARS = 4000
15export const RATE_LIMIT = 30
16export const RATE_WINDOW_MS = 10 * 60 * 1000
17/** Desktop bridge.ts:16: `timeout_seconds` default 600, clamped to 1-3600. */
18export const TIMEOUT_DEFAULT_S = 600
19export const TIMEOUT_MIN_S = 1
20export const TIMEOUT_MAX_S = 3600
21
22/** Desktop bridge.ts:16, clampTimeout. Seconds. */
23export function clampTimeout(v: unknown, def = TIMEOUT_DEFAULT_S): number {
24 return typeof v === 'number' && Number.isFinite(v) ? Math.min(TIMEOUT_MAX_S, Math.max(TIMEOUT_MIN_S, v)) : def
25}
26
27/** Desktop bridge.ts:65, truncate: the answer an `ask` returns, at most RESULT_MAX_CHARS plus a note of what was cut. */
28export function truncateResult(s: string, n = RESULT_MAX_CHARS): string {
29 return s.length <= n ? s : `${s.slice(0, n)}\n[truncated: ${s.length - n} more chars]`
30}
31
32/** Delivery times per `from>to` pair, for the rate limit. Plain JSON, so it can live in `$.state`. */
33export type RateLog = Readonly<Record<string, readonly number[]>>
34
35export type AskCheck =
36 | {
37 ok: true
38 /** The calling task (found by its current run id). */
39 caller: TaskView
40 target: AgentView
41 /** The new task's depth: how many tasks stand above it in the delegation chain (1 for a task the person's task asked). */
42 depth: number
43 /** The new run's bridge hop: the caller's hop + 1. */
44 hop: number
45 /** The rate log with this delivery recorded. Store it; the check never mutates its input. */
46 rateLog: RateLog
47 }
48 | { ok: false; reason: string; rateLog: RateLog }
49
50/** A task the person or Claude Code's own model started is hop 0; bridge and room runs carry theirs. */
51const hopOf = (t: TaskView): number => (t.origin.kind === 'bridge' || t.origin.kind === 'room' ? t.origin.hop : 0)
52const parentOf = (t: TaskView): string | undefined => (t.origin.kind === 'bridge' ? t.origin.fromTaskId : undefined)
53
54/**
55 * The Legion agent an Agent tool call names: `legion-mod:<name>` gives `<name>`; any other agent type (`Explore`,
56 * `general-purpose`, another plugin's) is not a Legion call and gives null. Trims, never lowercases: agent types are exact.
57 */
58export function parseLegionAgentType(subagentType: unknown): string | null {
59 if (typeof subagentType !== 'string') return null
60 const t = subagentType.trim()
61 if (!t.startsWith(AGENT_TYPE_PREFIX)) return null
62 const name = t.slice(AGENT_TYPE_PREFIX.length)
63 return /^[A-Za-z0-9_-]{1,64}$/.test(name) ? name : null
64}
65
66/**
67 * Whether a Legion agent's Agent tool call to another Legion agent may go ahead: ask is `isBlocking` (run_in_background false),
68 * tell is not. The desktop's guards and messages, in the desktop's order (bridge.ts:234-267): unknown caller task, empty message,
69 * unknown agent, self, depth, deadlock (blocking calls only), hop, rate.
70 *
71 * - `callerRunId`: the subagent id the call came from (the `agentId` of the tool.call). It finds the caller task by its current
72 * `runId`. A call from the person's own main loop has no caller task and is not a bridge call: do not judge it here.
73 * - `target`: the name parseLegionAgentType returned. Matched against the agent type names first, then (as on the desktop,
74 * bridge.ts:238-240) by id or name, case-insensitively, among visible agents only (hidden agents, such as the Assayer, cannot be
75 * reached).
76 * - The chain is walked through `origin.fromTaskId`, the mod's counterpart of the desktop's `parentTaskId`.
77 * - `waiting`: task ids currently blocked inside an ask (desktop `Bridge.waiting`).
78 * - On a pass the delivery is recorded in the returned rate log; on a rate refusal the log comes back pruned (bridge.ts:258-266).
79 */
80export function checkAsk(o: {
81 callerRunId: string | undefined
82 target: string
83 isBlocking: boolean
84 message: string
85 agents: readonly AgentView[]
86 tasks: readonly TaskView[]
87 waiting: ReadonlySet<string>
88 rateLog: RateLog
89 now: number
90}): AskCheck {
91 const fail = (reason: string, rateLog: RateLog = o.rateLog): AskCheck => ({ ok: false, reason, rateLog })
92 const byId = new Map(o.tasks.map((t) => [t.id, t] as const))
93 const caller = o.callerRunId === undefined ? undefined : o.tasks.find((t) => t.runId === o.callerRunId)
94 if (!caller) return fail('Unknown caller task')
95 if (!o.message?.trim()) return fail('message is empty')
96 const ref = String(o.target ?? '').trim()
97 const low = ref.toLowerCase()
98 const agents = o.agents.filter((a) => !a.isHidden)
99 const target = agents.find((a) => agentTypeName(a.id) === ref) ?? agents.find((a) => a.id.toLowerCase() === low) ?? agents.find((a) => a.name.toLowerCase() === low)
100 if (!target) return fail(`Unknown agent "${o.target}". Available: ${agents.filter((a) => a.id !== caller.agentId).map((a) => a.id).join(', ') || 'none'}`)
101 if (target.id === caller.agentId) return fail('You cannot message yourself')
102
103 // Ancestors of the caller, nearest first (bridge.ts:244-251).
104 const chain: TaskView[] = []
105 const seen = new Set<string>([caller.id])
106 for (let p = parentOf(caller); p && !seen.has(p) && chain.length < 16;) {
107 const t = byId.get(p)
108 if (!t) break
109 seen.add(p); chain.push(t); p = parentOf(t)
110 }
111 if (chain.length + 1 > MAX_DEPTH) return fail(`delegation too deep (max ${MAX_DEPTH})`)
112 if (o.isBlocking && chain.some((t) => t.agentId === target.id && o.waiting.has(t.id))) {
113 return fail(`would deadlock: ${target.name} is waiting on this chain. Use tell instead.`)
114 }
115 const hop = hopOf(caller) + 1
116 if (hop > MAX_HOP) return fail(`Bridge hop limit reached (${MAX_HOP}). Finish the work yourself or ask the user.`)
117 const key = `${caller.agentId}>${target.id}`
118 const recent = (o.rateLog[key] ?? []).filter((x) => o.now - x < RATE_WINDOW_MS)
119 if (recent.length >= RATE_LIMIT) {
120 return fail(`Rate limit: more than ${RATE_LIMIT} messages from ${caller.agentId} to ${target.id} in 10 minutes. Finish the work yourself or ask the user.`, { ...o.rateLog, [key]: recent })
121 }
122 return { ok: true, caller, target, depth: chain.length + 1, hop, rateLog: { ...o.rateLog, [key]: [...recent, o.now] } }
123}
124
125/** The header a bridged message starts with. Desktop bridge.ts:309-310, word for word. */
126export function bridgeHeader(fromName: string): string {
127 return `[From ${fromName} (Legion agent) via the bridge. Reply with just what they need; your final message is returned to them.]`
128}
129
130/** A `tell` reply as it lands in the caller's task. Desktop bridge.ts:366: `[Reply from X · task id] body`. */
131export function tellReply(fromName: string, fromTaskId: string, body: string): string {
132 return `[Reply from ${fromName} · task ${fromTaskId}] ${body}`
133}
134
135/** The body of a `tell` reply from the target task's outcome. Desktop bridge.ts:210 (status words as the mod names them). */
136export function tellBody(t: Pick<TaskView, 'status' | 'error'> | undefined, result: string | undefined, failure?: string): string {
137 if (failure !== undefined) return truncateResult(`(failed) ${failure}`)
138 if (t?.status === 'done') return truncateResult(result ?? '')
139 return truncateResult(`(${t?.status ?? 'gone'}) ${t?.error ?? ''}`.trim())
140}
141
142/** One line per other visible agent, for the `agents` tool. Desktop bridge.ts:102-112 (`id | name | description | status | thread`). */
143export function agentsList(callerAgentId: AgentId, agents: readonly AgentView[], tasks: readonly TaskView[]): string {
144 const lines: string[] = []
145 for (const a of agents) {
146 if (a.isHidden || a.id === callerAgentId) continue
147 const own = tasks.filter((t) => t.agentId === a.id)
148 const status = own.some((t) => t.status === 'running') ? 'working' : own.some((t) => t.status === 'queued') ? 'queued' : 'idle'
149 const thread = own.some((t) => t.origin.kind === 'bridge' && t.origin.fromAgentId === callerAgentId) ? 'thread' : 'no-thread'
150 lines.push(`${a.id} | ${a.name} | ${a.description.slice(0, 80)} | ${status} | ${thread}`)
151 }
152 return lines.length ? lines.join('\n') : 'No other agents.'
153}
154
155/** An `ask`'s answer comes back into the caller's context: a tainted answer taints the caller (desktop bridge.ts:190-191). */
156export function answerTaintsCaller(target: Pick<TaskView, 'isTainted'> | undefined): boolean {
157 return target?.isTainted === true
158}
159