SLOPSHOPPER

legion-mod

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

newpanebandrowsguardcommand
v0.2.0Apache-2.0updated 2026-10-08dnh33/legion-mod/plugins/legion-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · legion-mod
│ ┃ Legion ✕ › fix the failing auth test and add an audit log call │ ┃ ✠ LEGION 1: Chat 2: Order │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ✠ Your Order is ready: 11 agents. ⎿ Added 2 lines, removed 1 line │ ┃ Tell Marshal what you want. ⏺ Bash(bun test) │ ┃ It splits the work and hands it out. ⎿ 3 pass, 1 fail │ ┃ │ ┃ /to zealot <what you want done> ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ › /legion │ ⎿ legion-mod: Legion opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Legion
✠ LEGION 1: Chat 2: Order ──────────────────────────────────────────────────────── ✠ Your Order is ready: 11 agents. Tell Marshal what you want. It splits the work and hands it out. /to zealot <what you want done> k: keys
README

Legion Mod for Claude Code

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

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

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

Install

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

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

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

About

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

Source 52 files
hooks/register.tsx 13 lines
1/**
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}
13
src/ui/register-ui.tsx 134 lines
1/**
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}
134
src/wire/legion.tsx 1159 lines
1/**
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}
1159
types/index.d.ts 221 lines
1/**
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}
221
src/theme.ts 77 lines
1/**
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
77
src/ui/band.ts 113 lines
1/**
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}
113
src/ui/components.tsx 104 lines
1/**
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}
104
src/ui/dispatch.ts 75 lines
1/**
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}
75
src/ui/status.ts 54 lines
1/**
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}
54
src/ui/views/common.ts 168 lines
1/**
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())
168
src/ui/views/pane.ts 86 lines
1/**
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}
86
src/engine/bridge.ts 159 lines
1/**
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