One command for Claude Code: /agt plans, builds and reviews with the right model per step, steps aside on small tasks, and ends on one short card. Zero setup.

One command. Type /agt "task" in Claude Code and it plans, builds and reviews the work with the right model for each step. Small tasks stay cheap: it steps aside and just does them.
Install (about 30 seconds, no setup):
/plugin marketplace add hasuwini77/agentille
/plugin install agentille@hasuwini77-agentille
Try it in any repo:
/agt "add a dark mode toggle to the settings page"
What happens: it reads your repo, picks the lightest setup that works (one agent for small jobs, parallel agents only when the work really splits), hardens risky changes (auth, payments, data) with a red-team pass, and ends on one short card:
✓ dark mode toggle on /settings · 4 files · PR #12 · 6m
verify: npm test → 48 passed
review: PASS — code-reviewer design-reviewer
Open: ~/.agentille/state/run-a1b2/report.md
Next: merge PR #12
Everything else (plan, findings, each agent's full report) is in that report.md. Plain prompts are untouched: /agt only runs when you type it.
Make it sound like you (optional, 1 minute): /agentille-init. Powered by Systown AI Lab.
Plain Claude is one developer. /agt is that developer plus a planner and a reviewer, brought in only when the task is big or risky enough to pay for them.
| Good fit | Example |
|---|---|
| A multi-step feature | /agt "add a dark mode toggle to settings, persisted per user, with tests" |
| Two independent parts | /agt "add a CSV export endpoint and a download button on the reports page" |
| A risky change (auth, payments, data) | /agt "add webhook handling for subscription cancellations" |
| A review before merge | /agt "review the changes on this branch" |
| A bug across several files | /agt "debug why checkout totals are off by one cent" |
| Unsure of the scope | /agt --plan "migrate the auth pages to the app router" |
What you get over a plain prompt: a separate reviewer checks the work instead of the model that wrote it, each step runs on the model that fits it, independent parts build at the same time, a finished part's reviewers run together rather than one after another, and risky diffs get a red-team pass.
Skip it for one-file edits, typos, renames and questions: a plain prompt is cheaper. Any run that is not solo costs more tokens than a plain prompt. What you buy is fewer wrong or unreviewed changes, not a lower bill.
| You type | You get | ||
|---|---|---|---|
/agt --plan "task" | The plan and roster first; say "go" to build it | ||
| `/agt --mode panes\ | subagent\ | solo "task"` | Force a mode for one run |
/agt --fable "task" | The top model tier on judgment roles for one run | ||
| `/agt --formation duel\ | gauntlet\ | relay "task"` | Two builds and a judge, an adversary pass, or a contract-first parallel build |
/agt-spawn "task" [--model …] | One extra routed Claude pane (Herdr or tmux), yours to keep | ||
| `/agt-deck [auto\ | off]` | A side pane with the run's cast, routing timeline, wire log and token bars · open it on every /agt · stop | |
/agt-tell <worker> <message> | Message a worker pane over the wire | ||
/agt-routing · /agt-ledger | Routing decisions · tokens per role this session | ||
| `/agt-highlight on\ | all\ | off` | Essentials card and lit paths on /agt replies · on every long reply · off |
/agentille-project · /agentille-claude-md | Seed a repo's CLAUDE.md · tune an existing one |
plan ✓ · build ◆◇ · review ◆◆◆ · 2/6 done), however many there are. Each subagent (◇) and pane session (▣) gets a row with its model pill, the tool it is running, elapsed time and tokens; ↑ marks an escalated route. Reviewers nest under the agent that spawned them, the agent you are viewing is highlighted, and a waiting or failed agent says so. Drawn by the mod, zero model tokens.Agent call in the conversation becomes a live row (role, model · effort, current tool, elapsed, tokens), and a pinned status line under the prompt keeps ◇ working · done · tokens · $ session in view when the band is collapsed. Dollar figures are the measured session cost only; per-agent numbers are tokens./agt refuses to open panes when the session runs an older agentille than the one installed (restart first)./agt -- completes its flags and their values.↗ jumps to its pane. tmux works with the same band; without a multiplexer you get the subagent tree.--fable.Claude Code 2.1.294+ for the mod (switchboard, routing, the wire, pane tools, deck). Without it the skill still runs, with explicit models. No profile file needed.
The executor's debugging, test-first and verification discipline is informed by Jesse Vincent's superpowers (MIT), internalized, not bundled.
MIT, see LICENSE. By Systown AI Lab.
hooks/register.js 1183 lines1// agentille mod — routing, the switchboard band, the wire between sessions, the /agt-deck pane,
2// squads, the pane reaper (herdr and tmux), the worker mascot and focus. Policy and state live in
3// routing.js / live.js / board.js / wire.js / squads.js / mascot.js / focus.js; this file
4// observes events, applies decisions, and draws.
5
6import { DEFAULTS, decide, formationOf, parseHeader, roleOf, verdictOf } from './routing.js'
7import { addUsage, elapsed, endRoute, finish, isAgtPrompt, ledger, ledgerText, newAgent, paneAgents, panesLeft, reapPlan, short, stage, waving } from './live.js'
8import { ACCENT, FRAME_COLOR, INK, WIRE_COLOR, boardRows, cast, castColumns, chip, colorOf, header, hex, leadLine, routingLines, swarm, tokenBars, toolLabel, wireLines, wireRow } from './board.js'
9import { PUBLISH_MS, doneMessage, freshStatus, logWire, parseTellArgs, parseWire, shortName, statusKey, validLead, wakeDue, wakeMessage, wireEnv, workerStatus } from './wire.js'
10import { activeSquads, allPaths, depsOf, injection } from './squads.js'
11import { BYE_MS, HELLO_MS, MASCOT_COLOR, MODEL_COLOR, agentMood, caption, frame, modelKey, moodAt, parseWorker } from './mascot.js'
12import { MARK, SPAN_COLOR, highlightFor, highlightText, litFor, parseHighlightArgs, spans } from './highlight.js'
13import { flagOf, focusText, paneFlags, parseFocusArgs } from './focus.js'
14import {
15 CLOSE_TOOL, HERDR_START_TIMEOUT, PROBE, SPAWN_TOOL, closeTarget, focusArgv, herdrMetaArgv, spawnToolInput, SAFE_RUN, SPAWN_ROLE, TMUX_LIST_ARGV, answerFile, doneFile, herdrCloseArgv, herdrPaneIdOf, herdrPromptArgv, herdrSplitArgv, herdrStartArgv,
16 isFreshDone, isLead, newRunId, paneKey, paneName, paneRole, parseSpawnArgs, parseTmuxList, pickTransport, quietSpawn, reapPool, scopeRows, splitName, splitPlan, tmuxEvenArgv, tmuxKillArgv, tmuxPaneAgents, tmuxPaneIdOf,
17 advisorEnv, tmuxSplitArgv, tmuxTagArgvs, transportBlock,
18} from './panes.js'
19import { cacheDirOf, compareVersions, newerInstalled, skewMessage } from './skew.js'
20import { adopt, applyStatus, nest, quietWorkflows } from './tree.js'
21import { registerRows } from './rows.js'
22import { registerStatus } from './status.js'
23import { registerAutocomplete } from './autocomplete.js'
24
25let settings = { ...DEFAULTS }
26let depth = null
27let home = null
28let weeklyPct = null
29let lastRun = 'adhoc'
30const wfToasted = new Set() // run:role pairs whose drift the person was told once
31const runs = new Map() // run id → { revise, fixes, fable, formation, log: [], reports: { role → count } }
32const live = new Map() // agentId → live agent (live.js)
33const decisions = [] // this session, for /agt-routing
34let panes = [] // agt-* panes (herdr or tmux) other than this one
35const paneRoutes = [] // PaneRoute[], append-only: how each worker this lead opened was routed
36let opened = [] // { id, name } of panes this lead opened via spawn_pane, in spawn order
37const staged = new Map() // pane name → last pane seen on stage (live.js panesLeft)
38const paneSeen = new Map() // reaper bookkeeping
39const harvested = new Set() // pane ids whose answer the lead has in hand (a wire done message, a saved answer file, a close_pane)
40const wireDone = new Set() // pane ids whose wire done message reached this lead
41const woken = new Set() // pane ids the lead already woke itself for
42const strandedNow = new Set() // done/idle panes kept open because nothing was harvested from them
43const strandedToasted = new Set() // ... pane ids of which the person was told once
44const firstSeen = new Map() // pane id → when this session first listed it: a done-file or answer file older than that is a previous pane's
45let selfName = null // this pane's name when it is an agt-* worker
46let worker = null // { agent, model, effort } from AGENTILLE_WORKER: this session is a worker pane
47let selfTab = null // herdr: the lead's own tab, where its workers split
48let paneTools = false // spawn_pane / close_pane registered for this lead
49let squads = [] // active squads for this repo
50let squadBlock = ''
51let transport = null // 'herdr' | 'tmux' | 'none', probed once at session start
52let tick = 0
53let pollTimer = null // pane polling: runs only while panes may exist
54let quietPolls = 0
55let tickTimer = null // redraw ticker: runs only while something works
56let focusOn = true // agent flags show above the prompt
57let flags = [] // [{ text, at }] from agent results this run
58let agtTurn = false // this turn is part of an /agt run
59let leadTurn = false // a main-loop turn is running: its idle panes are waiting for review
60const mascot = { greeted: false, hiUntil: 0, byeUntil: 0, working: false, start: 0, ms: null } // worker band, in $.clock.now() ms
61let highlightOn = true // /agt replies get an essentials card and lit tokens
62let highlightAll = false // …and so does every other long reply (/agt-highlight all)
63const litMemo = new Map() // message id → was it an /agt turn when first drawn
64const hlMemo = new Map() // message id → { text, highlight() result }
65let runningVersion = null // this module's own agentille version, read from its plugin.json at session start
66let skewToasted = false
67let sessionId = null // this session's id: workers it opens send their results here
68let leadModel = null // the main loop's model, for the tree's root
69const wireLog = [] // [{ from, to, kind, summary, at }] messages between sessions, newest last
70const wireStatus = new Map() // pane name → what its worker last published (wire.js workerStatus)
71let wireName = null // worker: its own agt- name (AGENTILLE_NAME), where it publishes
72let leadSession = null // worker: the lead's session id (AGENTILLE_LEAD), where it reports
73const pub = { state: 'starting', tool: null, tok: 0, model: null, effort: null, start: null, at: 0 } // worker's published status
74let lastMeta = '' // worker: the last Herdr sidebar title it reported
75let deckAuto = false // /agt-deck auto: the deck opens on every typed /agt
76const DECK = 'agt-deck'
77const FLAG_TTL_MS = 30 * 60_000
78const FOCUS_COLOR = { next: '#3fb950', flag: '#f85149' }
79const LIST_MS = 1000 // $.agent.list() is read at most this often
80const LIST_RETRY_MS = 30_000 // ... and after a failed read, at most this often
81let listAt = 0
82let listBusy = false
83
84// What the modules wired last in register(on) read: live state through getters, since most of
85// it is reassigned.
86const ctx = {
87 live,
88 get run() { return lastRun },
89 get tick() { return tick },
90 get panes() { return panes },
91 get routes() { return paneRoutes },
92 get isLead() { return !worker && !selfName },
93 onTick: [], // (io, ctx, now) callbacks the redraw ticker calls each period; a module adds its own while registering
94}
95
96// A workflow agent is in no agent list, and a cancelled one raises no turn.complete: it is marked,
97// and every event of its loop says it is still alive (tree.js quietWorkflows ends a silent one).
98function heard(a, e) {
99 if (e.workflow) a.workflow = e.workflow.runId || true
100 a.heard = Date.now()
101 return a
102}
103
104function runState(id) {
105 if (!runs.has(id)) runs.set(id, { revise: 0, fixes: 0, fable: 0, formation: null, log: [], reports: {} })
106 return runs.get(id)
107}
108
109async function probeOk($, argv) {
110 try {
111 return (await $.process.run(argv, PROBE)).exitCode === 0
112 } catch {
113 return false
114 }
115}
116
117// herdr → tmux → none. A probe that rejects or exits non-zero counts as unavailable; so
118// does a host with no $.process (desktop app, VS Code), where the call itself throws.
119async function detectTransport($) {
120 const herdrOk = (await $.env.get('HERDR_ENV')) === '1' && (await probeOk($, ['herdr', '--version']))
121 const tmuxOk = !herdrOk && ((await $.env.get('TMUX')) ?? '') !== '' && (await probeOk($, ['tmux', '-V']))
122 return pickTransport({ herdrOk, tmuxOk })
123}
124
125async function transportOf($) {
126 transport ??= await detectTransport($)
127 return transport
128}
129
130function shortType(t) {
131 return String(t ?? 'agent').split(':').pop()
132}
133
134// The newer agentille version installed beside the one this session runs, or null: nothing found,
135// nothing readable (a checkout, a host without a plugin cache) all read as no skew. A replaced version
136// stays in the cache with an .orphaned_at marker; it is not installed, so only a newer folder without
137// one counts (one lookup per newer folder).
138async function installedSkew($) {
139 if (!runningVersion) return null
140 try {
141 const dir = cacheDirOf($.plugin.root)
142 if (!dir) return null
143 const live = []
144 for (const e of await $.fs.list(dir)) {
145 if (e.kind === 'dir' && compareVersions(e.name, runningVersion) > 0 && !(await $.fs.exists(dir + '/' + e.name + '/.orphaned_at'))) live.push(e.name)
146 }
147 return newerInstalled(runningVersion, live)
148 } catch {
149 return null
150 }
151}
152
153async function loadProfile($) {
154 home = (await $.env.get('HOME')) ?? null
155 if (!home) return
156 try {
157 const p = JSON.parse(await $.fs.read(home + '/.agentille/profile.json'))
158 depth = p.thinkingDepth ?? null
159 settings = { ...DEFAULTS, ...(p.routing ?? {}) }
160 } catch {
161 // no profile yet: /agt itself tells the user to run /agentille-init
162 }
163}
164
165async function loadSquads($) {
166 try {
167 const config = JSON.parse(await $.fs.read($.plugin.root + '/.claude-plugin/squads.json'))
168 const cwd = await $.session.cwd()
169 let pkg = ''
170 try { pkg = await $.fs.read(cwd + '/package.json') } catch { /* not a node repo */ }
171 const existing = new Set()
172 for (const p of allPaths(config)) if (await $.fs.exists(cwd + '/' + p)) existing.add(p)
173 squads = activeSquads(config, depsOf(pkg), existing)
174 squadBlock = injection(squads)
175 } catch {
176 squads = []
177 squadBlock = ''
178 }
179}
180
181// The engine's agent list is the truth for who exists and who spawned whom. Read it at session
182// start (a reload loses the live map) and while agents work; render hooks only see the copy.
183// A failed read leaves everything as it was.
184async function refreshAgents($, at) {
185 const now = at ?? await $.clock.now()
186 if (listBusy || now - listAt < LIST_MS) return
187 listBusy = true
188 listAt = now
189 try {
190 if (quietWorkflows(live, Date.now())) $.ui.invalidate('ui.render')
191 let list
192 try {
193 list = await $.agent.list()
194 } catch {
195 listAt = now + LIST_RETRY_MS - LIST_MS // no agent list here (an older build, a host without agents): the band runs on events alone, and the list is retried rarely
196 }
197 if (!Array.isArray(list)) return
198 const added = adopt(live, list, { run: lastRun, now }).length > 0
199 const changed = applyStatus(live, list, now)
200 if (working()) ensureTicker($)
201 if (added || changed) $.ui.invalidate('ui.render')
202 } finally {
203 listBusy = false
204 }
205}
206
207async function writeRunFile($, runId, name, text) {
208 if (!home || !SAFE_RUN.test(runId) || runId === 'adhoc') return
209 try {
210 await $.fs.write(home + '/.agentille/state/run-' + runId + '/' + name, text)
211 } catch {
212 // the run dir is the orchestrator's; logging never blocks a dispatch
213 }
214}
215
216// A pane that left the list takes its bookkeeping with it, so a reused pane id never inherits a harvest.
217function forgetGone(listed) {
218 const gone = (id) => !listed.some((p) => p.id === id)
219 for (const id of [...firstSeen.keys()]) if (gone(id)) firstSeen.delete(id)
220 for (const s of [harvested, wireDone, woken, strandedToasted]) for (const id of [...s]) if (gone(id)) s.delete(id)
221}
222
223// Panes that left the stage this poll: end their route and refresh the ledger.
224async function notePaneExits($) {
225 const touched = new Set()
226 for (const name of panesLeft(staged, panes)) {
227 const r = endRoute(paneRoutes, name, Date.now())
228 if (r) touched.add(r.run)
229 }
230 for (const run of touched) await writeRunFile($, run, 'ledger.json', JSON.stringify(ledger(live, run, paneRoutes), null, 2) + '\n')
231}
232
233// A file counts only if it was written once this session knew the pane: a role can be respawned, and
234// the earlier pane's answer is not this worker's.
235async function freshAnswer($, file, p) {
236 if (!file) return false
237 try {
238 return isFreshDone(await $.fs.stat(file), firstSeen.get(p.id))
239 } catch {
240 return false // missing or unreadable
241 }
242}
243
244// Where a pane worker saved its full answer, or null. Each pane instance writes its own file
245// (pane-<role>.<pane>.md). An older worker's legacy pane-<role>.md counts only while one pane has the
246// name: with two, it could be either one's.
247async function answerOf($, p) {
248 const n = splitName(p.name)
249 if (!n) return null
250 const own = answerFile(home, n.run, n.role, paneKey(p.id))
251 if (await freshAnswer($, own, p)) return own
252 // Known limit: two keyless twins (an older worker's one legacy file) cannot be told apart, so with two panes of the
253 // name neither is harvested by it and both stay flagged.
254 if (panes.filter((q) => q.name === p.name).length !== 1) return null
255 const legacy = answerFile(home, n.run, n.role)
256 return (await freshAnswer($, legacy, p)) ? legacy : null
257}
258
259async function isHarvested($, p) {
260 if (harvested.has(p.id)) return true
261 if (!(await answerOf($, p))) return false
262 harvested.add(p.id)
263 return true
264}
265
266// A pane finished and no wire done message came: the lead's own session gets one message naming
267// the pane and how to read it, so the lead does not wait blind. Once per pane. A blocked pane waits on the person.
268async function wakeLead($, pool, now, t) {
269 if (!sessionId) return
270 for (const p of wakeDue(pool, paneSeen, wireDone, woken, now)) {
271 woken.add(p.id)
272 const text = wakeMessage({ name: p.name, transport: t, id: p.id, answerPath: await answerOf($, p) })
273 const r = await $.session.send({ to: { sessionId }, text }).catch((err) => ({ isDelivered: false, reason: String(err?.message ?? err) }))
274 if (!r.isDelivered) $.ui.toast('agt ⚑ ' + p.name + ' ' + p.state + ' with no report. ' + text.slice(text.indexOf('. ') + 2).slice(0, 120))
275 }
276}
277
278// The lead's reaper. A finished pane closes only once its answer is in hand; until then it stays
279// open and is flagged once, so a worker that could not report never loses its output.
280async function sweep($, pool, now, t, close) {
281 const ok = new Set()
282 for (const p of pool) if ((p.state === 'done' || p.state === 'idle') && (await isHarvested($, p))) ok.add(p.id)
283 const plan = reapPlan(pool, paneSeen, now, leadTurn, (p) => ok.has(p.id))
284 strandedNow.clear()
285 for (const p of plan.stranded) {
286 strandedNow.add(p.name)
287 if (strandedToasted.has(p.id)) continue
288 strandedToasted.add(p.id)
289 $.ui.toast('agt ⚑ ' + p.name + ' ' + p.state + ', not harvested')
290 }
291 for (const p of plan.reap) {
292 try {
293 await close(p)
294 $.ui.toast('agt reaped ' + p.name + ' (' + p.state + ')')
295 } catch {
296 // a pane closed by hand in the meantime is fine
297 }
298 }
299 await wakeLead($, pool, now, t)
300}
301
302async function pollHerdr($) {
303 let list = []
304 try {
305 const r = await $.process.run(['herdr', 'agent', 'list'], PROBE)
306 list = JSON.parse(r.stdout).result?.agents ?? []
307 } catch {
308 return
309 }
310 const selfPane = (await $.env.get('HERDR_PANE_ID')) ?? null
311 const me = list.find((p) => p.pane_id === selfPane)
312 selfName = me && typeof me.name === 'string' && me.name.startsWith('agt-') ? me.name : null
313 selfTab = me?.tab_id ?? null
314 // Show the workspace's agt-* panes; reap only the lead's own tab, where herdr mode
315 // splits its workers. Workers (panes that are themselves agt-*) never reap.
316 panes = quietSpawn(paneAgents(list, selfPane).filter((p) => !me || p.workspace === me.workspace_id))
317 const now = await $.clock.now()
318 for (const p of panes) if (!firstSeen.has(p.id)) firstSeen.set(p.id, now)
319 forgetGone(panes)
320 await notePaneExits($)
321 if (me && !selfName) {
322 const mine = panes.filter((p) => p.tab === me.tab_id)
323 await sweep($, reapPool(mine), now, 'herdr', (p) => $.process.run(herdrCloseArgv(p.id)))
324 }
325 $.ui.invalidate('ui.render')
326}
327
328async function runOk($, argv, init) {
329 const r = await $.process.run(argv, init)
330 if (r.exitCode !== 0) throw new Error(argv.slice(0, 3).join(' ') + ' exited ' + r.exitCode + (r.stderr ? ': ' + String(r.stderr).trim().slice(0, 120) : ''))
331 return r
332}
333
334// One claude pane beside this one, named agt-<run>-<role>, never focused. A half-made
335// pane is closed again before the error goes up. `split` stacks it beside an earlier worker
336// instead of the lead. Returns the new pane id.
337async function openPane($, t, o) {
338 if (t === 'tmux') {
339 const lead = await $.env.get('TMUX_PANE')
340 if (!lead) throw new Error('TMUX_PANE is not set')
341 const target = o.split?.target ?? lead
342 const shell = (await $.env.get('SHELL')) ?? ''
343 const id = tmuxPaneIdOf((await runOk($, tmuxSplitArgv({ ...o, target, shell, direction: o.split?.direction, env: [...wireEnv({ name: o.name, lead: sessionId }), ...advisorEnv(o.model, settings)] }), PROBE)).stdout)
344 if (!id) throw new Error('tmux gave no pane id')
345 try {
346 for (const argv of tmuxTagArgvs(id, o.name)) await runOk($, argv, PROBE)
347 } catch (err) {
348 await $.process.run(tmuxKillArgv(id), PROBE).catch(() => {})
349 throw err
350 }
351 // Even out the stack; a cosmetic failure never loses the worker.
352 if (o.split && o.split.target !== lead) await $.process.run(tmuxEvenArgv(id), PROBE).catch(() => {})
353 return id
354 }
355 const lead = await $.env.get('HERDR_PANE_ID')
356 if (!lead) throw new Error('HERDR_PANE_ID is not set')
357 const pane = o.split?.target ?? lead
358 const id = herdrPaneIdOf((await runOk($, herdrSplitArgv({ pane, cwd: o.cwd, run: o.run, direction: o.split?.direction, agent: o.agent, model: o.model, effort: o.effort, env: [...wireEnv({ name: o.name, lead: sessionId }), ...advisorEnv(o.model, settings)] }), PROBE)).stdout)
359 if (!id) throw new Error('herdr gave no pane id')
360 try {
361 // A fresh pane is busy while its shell starts up (a login banner, a slow rc): herdr says
362 // agent_pane_busy, so wait and try again for up to ~10s.
363 for (let attempt = 0; ; attempt++) {
364 const r = await $.process.run(herdrStartArgv({ name: o.name, pane: id, model: o.model, effort: o.effort, agent: o.agent }), HERDR_START_TIMEOUT)
365 if (r.exitCode === 0) break
366 if (!/agent_pane_busy/.test(r.stderr ?? '') || attempt >= 20) throw new Error('herdr agent start exited ' + r.exitCode + (r.stderr ? ': ' + String(r.stderr).trim().slice(0, 120) : ''))
367 await $.clock.sleep(500)
368 }
369 await runOk($, herdrPromptArgv(o.name, o.task), PROBE)
370 } catch (err) {
371 await $.process.run(herdrCloseArgv(id), PROBE).catch(() => {})
372 throw err
373 }
374 // Herdr's sidebar names the worker by role and route; cosmetic, so a failure is ignored.
375 const meta = herdrMetaArgv(id, { display: '▣ ' + shortName(o.name) + ' · ' + o.model })
376 if (meta) await $.process.run(meta, PROBE).catch(() => {})
377 return id
378}
379
380// tmux has no agent status, so a pane is working until its done-file appears or tmux
381// calls it dead. Panes are scoped to the lead's own window; only an unnamed lead reaps,
382// and tmux has no idle state, so only the 90s done grace applies.
383async function pollTmux($) {
384 let rows = []
385 try {
386 const r = await $.process.run(TMUX_LIST_ARGV, PROBE)
387 if (r.exitCode !== 0) return
388 rows = parseTmuxList(r.stdout)
389 } catch {
390 return
391 }
392 const selfPane = (await $.env.get('TMUX_PANE')) ?? null
393 const me = rows.find((r) => r.id === selfPane)
394 selfName = me && me.agt.startsWith('agt-') ? me.agt : null
395 // Window-scope first: no file is read for a pane this lead does not own. A done-file counts
396 // only if it was written after the pane was first listed.
397 const now = await $.clock.now()
398 const scoped = me ? scopeRows(rows, selfPane) : []
399 for (const r of scoped) if (!firstSeen.has(r.id)) firstSeen.set(r.id, now)
400 forgetGone(rows)
401 const done = new Map()
402 for (const r of scoped) {
403 const n = splitName(r.agt)
404 const file = n && n.role !== SPAWN_ROLE && !r.dead && doneFile(home, n.run, n.role)
405 if (!file) continue
406 try {
407 done.set(r.agt, isFreshDone(await $.fs.stat(file), firstSeen.get(r.id)))
408 } catch {
409 // missing or unreadable counts as not done
410 }
411 }
412 panes = tmuxPaneAgents(rows, selfPane, done)
413 await notePaneExits($)
414 if (isLead(rows, selfPane)) {
415 await sweep($, reapPool(panes), now, 'tmux', (p) => $.process.run(tmuxKillArgv(p.id), PROBE))
416 }
417 $.ui.invalidate('ui.render')
418}
419
420async function pollNow($, t) {
421 if (t === 'herdr') await pollHerdr($)
422 else if (t === 'tmux') await pollTmux($)
423}
424
425// A subagent's raw answer goes to run-<id>/agents/<role>-<n>.md, written here so the lead spends
426// no tokens copying it. n counts per role in the run; a second turn of the same agent overwrites.
427async function writeReport($, a, answer) {
428 if (!a.routed || !answer) return
429 const counts = runState(a.run).reports
430 a.report ??= a.role + '-' + (counts[a.role] = (counts[a.role] ?? 0) + 1) + '.md'
431 await writeRunFile($, a.run, 'agents/' + a.report, answer)
432}
433
434// The run an agent's files and counters belong to: a headerless workflow agent files under 'adhoc'.
435const fileRunOf = (a) => a.fileRun ?? a.run
436
437const working = () => [...live.values()].some((a) => a.state === 'working') || panes.some((p) => p.state === 'working')
438
439// Pane polling starts when panes can exist (an /agt run, a spawn, panes found at start) and
440// stops after a minute with no agt- pane and nothing working.
441function ensurePolling($) {
442 if (pollTimer || !transport || transport === 'none') return
443 quietPolls = 0
444 pollTimer = $.clock.every(5000, () => {
445 pollNow($, transport).then(() => {
446 if (panes.length || working()) { quietPolls = 0; ensureTicker($); return }
447 if (++quietPolls >= 12) { pollTimer?.cancel(); pollTimer = null }
448 })
449 })
450}
451
452// Redraw while something works, a worker's mascot is animating or a finished subagent is
453// still waving (elapsed times tick, legs step); the ticker cancels itself once all are idle.
454const animating = (now) => (!!worker && moodAt({ ...mascot, now }) !== 'idle') || [...live.values()].some((a) => waving(a, now))
455
456// The modules that follow the run on the ticker (the pinned status line) add a callback to ctx.onTick.
457// They get the few `$` calls they need as `io`, not `$`: the hook checker follows `$` itself only
458// into top-level functions of one file, so the arrows that spell the calls out sit here.
459async function tickModules($, now) {
460 const io = { status: (text) => $.ui.status(text), after: (ms, fn) => $.clock.after(ms, fn), now: () => $.clock.now() }
461 for (const f of ctx.onTick) {
462 try {
463 await f(io, ctx, now)
464 } catch {
465 // one module's tick must not stop the others or the redraw
466 }
467 }
468}
469
470function ensureTicker($) {
471 if (tickTimer) return
472 tickTimer = $.clock.every(300, async () => {
473 const now = await $.clock.now()
474 if (!working() && !animating(now)) {
475 tickTimer?.cancel()
476 tickTimer = null
477 } else tick += 1
478 await tickModules($, now) // also on the tick that stops the ticker, so the last change is seen
479 if (panes.length && tick % 3 === 0) await readWire($)
480 if (tick % 4 === 0) void refreshAgents($, now)
481 $.ui.invalidate('ui.render')
482 })
483}
484
485function addFlag($, text) {
486 if (!text) return
487 const now = Date.now()
488 flags = flags.filter((f) => now - f.at < FLAG_TTL_MS && f.text !== text)
489 flags.push({ text, at: now })
490 $.ui.toast('agt ⚑ ' + text)
491}
492
493function focusLines(now) {
494 if (!focusOn) return []
495 const fresh = flags.filter((f) => now - f.at < FLAG_TTL_MS).map((f) => f.text)
496 return [...fresh, ...paneFlags(panes, strandedNow)].slice(0, 5)
497}
498
499// One Text per essentials item: a coloured mark, then the line with paths, versions and numbers lit.
500function cardBox(els, items) {
501 const { Box, Text } = els
502 return Box({
503 flexDirection: 'column',
504 children: items.map((item) => {
505 const color = FOCUS_COLOR[item.kind]
506 return Text({
507 ...(color ? { color } : { dimColor: true }),
508 wrap: 'truncate',
509 children: [
510 Text({ ...(color ? { color } : { dimColor: true }), children: [MARK[item.kind] + ' '] }),
511 ...spans(item.text).map((sp) => (sp.kind === 'plain' ? sp.text : Text({ color: SPAN_COLOR[sp.kind], children: [sp.text] }))),
512 ],
513 })
514 }),
515 })
516}
517
518const focusRow = (els, text) => els.Text({ color: FOCUS_COLOR.flag, wrap: 'truncate', children: ['⚑ ' + text] })
519
520// ── the wire (worker side publishes and reports; lead side reads) ─────────────
521
522// Worker: what it is doing, into the shared store, at most once per PUBLISH_MS unless forced.
523// Publishes run one after another, so a late tool-call publish never lands over a forced 'done'.
524let pubChain = Promise.resolve()
525function publish($, force = false) {
526 const p = pubChain.then(() => publishNow($, force))
527 pubChain = p.catch(() => {})
528 return p
529}
530
531async function publishNow($, force) {
532 if (!wireName) return
533 const now = Date.now()
534 if (!force && now - pub.at < PUBLISH_MS) return
535 pub.at = now
536 await $.store.set(statusKey(wireName), workerStatus({ name: wireName, session: sessionId, ...pub, now })).catch(() => {})
537 if (transport !== 'herdr') return
538 // Herdr's sidebar follows the worker: its route, then what it is doing. Changes only.
539 const title = pub.state === 'done' ? '✓ done ' + elapsed(pub.ms ?? 0) : pub.tool ?? pub.state
540 if (title === lastMeta) return
541 lastMeta = title
542 const pane = await $.env.get('HERDR_PANE_ID')
543 const argv = pane && herdrMetaArgv(pane, { display: '▣ ' + shortName(wireName) + ' · ' + (worker?.model ?? short(pub.model)), title })
544 if (argv) await $.process.run(argv, PROBE).catch(() => {})
545}
546
547// Worker: the full answer of a finished turn, saved under the run where the lead reads it. Returns the path, or null.
548async function saveAnswer($, name, answer) {
549 const n = splitName(name)
550 if (!n || !answer || !home) return null
551 // One file per pane instance, named by this pane's id, so a respawned role cannot overwrite or be read as its predecessor.
552 const pane = (await $.env.get('HERDR_PANE_ID')) ?? (await $.env.get('TMUX_PANE'))
553 const file = answerFile(home, n.run, n.role, paneKey(pane))
554 if (!file) return null
555 await writeRunFile($, n.run, 'agents/' + file.split('/').pop(), answer)
556 return file
557}
558
559// Worker: a finished turn saves the full answer under the run and messages the lead its head,
560// so the lead wakes on its own and never scrapes the pane.
561async function reportDone($, answer) {
562 const reportPath = await saveAnswer($, wireName, answer)
563 if (!leadSession) return
564 const text = doneMessage({ name: wireName, ms: pub.ms ?? 0, model: worker?.model ?? short(pub.model), effort: worker?.effort || pub.effort, tok: pub.tok, answer, reportPath })
565 const r = await $.session.send({ to: { sessionId: leadSession }, text }).catch((err) => ({ isDelivered: false, reason: String(err?.message ?? err) }))
566 if (r.isDelivered) logWire(wireLog, { from: shortName(wireName), to: 'lead', kind: 'done', summary: 'reported to the lead', at: Date.now() })
567 else $.ui.toast('agt wire: the lead did not get the result (' + String(r.reason).slice(0, 80) + ')')
568}
569
570// Lead: what each visible worker last published.
571async function readWire($) {
572 const now = Date.now()
573 for (const p of panes) {
574 const rec = await $.store.get(statusKey(p.name)).catch(() => null)
575 if (freshStatus(rec, p.name, now)) wireStatus.set(p.name, rec)
576 }
577 for (const k of [...wireStatus.keys()]) if (!panes.some((p) => p.name === k)) wireStatus.delete(k)
578}
579
580// ── drawing (takes resolved elements, never $) ────────────────────────────────
581
582function boardHeader(els, h) {
583 const { Box, Text } = els
584 return Box({
585 flexDirection: 'row',
586 justifyContent: 'space-between',
587 children: [
588 Text({ wrap: 'truncate', children: [Text({ color: hex(ACCENT), children: ['◆ agentille'] }), Text({ dimColor: true, children: [' ' + h.left] })] }),
589 Text({ dimColor: true, children: [h.right] }),
590 ],
591 })
592}
593
594// The whole run in one line, by phase: plan ✓ · build ◆◇ · review ◆◆◆✓ · 3/7 done.
595function swarmLine(els, s) {
596 const { Text } = els
597 const parts = []
598 s.lanes.forEach((l, i) => {
599 parts.push(Text({ dimColor: true, children: [(i ? ' ' : '') + l.phase + ' '] }))
600 for (const c of l.cells) parts.push(Text({ color: c.color, dimColor: c.dim, children: [c.glyph] }))
601 if (l.more) parts.push(Text({ dimColor: true, children: ['+' + l.more] }))
602 })
603 parts.push(Text({ dimColor: true, children: [' ' + s.done + '/' + s.total + ' done'] }))
604 return Text({ wrap: 'truncate', children: parts })
605}
606
607function leadRow(els, l) {
608 const { Text } = els
609 return Text({ wrap: 'truncate', children: [Text({ color: l.color, children: ['◉ '] }), l.text, Text({ dimColor: true, children: [' ' + l.state] })] })
610}
611
612// One agent: tree · kind glyph · role · transport · model pill · effort · spinner + what it does ·
613// elapsed · tokens, and a ↗ that jumps to a pane worker.
614function boardRow(els, r, onFocus, slot = false) {
615 const { Box, Text, Button } = els
616 const kids = [Text({ dimColor: true, children: [r.tree] }), Text({ color: r.glyphColor, children: [r.glyph] }), Text({ dimColor: r.dim, ...(r.inView ? { inverse: true } : {}), children: [r.role] })]
617 if (r.kind) kids.push(Text({ dimColor: true, children: [r.kind] }))
618 kids.push(r.dim ? Text({ color: r.chipColor, dimColor: true, children: [r.chip] }) : Text({ color: INK, backgroundColor: r.chipColor, children: [r.chip] }))
619 kids.push(Text({ color: colorOf('fable'), children: [r.escalated ? '↑' : ' '] }))
620 if (r.effort) kids.push(Text({ dimColor: true, children: [r.effort] }))
621 kids.push(Box({ flexGrow: 1, flexShrink: 1, children: [Text({ wrap: 'truncate', dimColor: r.dim, children: [Text({ color: r.glyphColor, children: [r.spinner + ' '] }), r.activity] })] }))
622 kids.push(Text({ dimColor: true, children: [r.time] }))
623 if (r.tok) kids.push(Text({ dimColor: true, children: [r.tok] }))
624 if (onFocus) kids.push(Button({ key: 'focus:' + r.id, label: '↗', plain: true, dimColor: true, onPress: onFocus }))
625 else if (slot) kids.push(Text({ children: [' '] })) // keeps the columns of rows without a ↗ in line
626 return Box({ flexDirection: 'row', columnGap: 1, children: kids })
627}
628
629function wireLine(els, w) {
630 const { Text } = els
631 return Text({ wrap: 'truncate', children: [Text({ color: hex(WIRE_COLOR), children: ['⇄ ' + w.arrow + ' '] }), w.text, Text({ dimColor: true, children: [' ' + w.ago] })] })
632}
633
634const rule = (els, label) => els.Text({ dimColor: true, children: ['── ' + label + ' ' + '─'.repeat(Math.max(0, 40 - label.length))] })
635
636// A worker's own band: the mascot's three rows over a caption, or the caption alone when the
637// band is short, off a terminal, or nothing is animating.
638function workerBand(els, e, now) {
639 const { Box, Text } = els
640 const mood = moodAt({ ...mascot, now })
641 const cap = caption({ mood, ...worker, ms: mood === 'working' ? now - mascot.start : mascot.ms })
642 const capText = Text({ color: hex(MODEL_COLOR[modelKey(worker.model)]), wrap: 'truncate', children: [cap] })
643 if (mood === 'idle' || e.surface !== 'terminal' || (e.props.maxRows ?? 0) < 5) return [capText]
644 const [head, body, legs] = frame(mood, tick >> 1, worker.agent)
645 const orange = hex(MASCOT_COLOR)
646 return [Text({ color: orange, children: [head] }), Text({ color: orange, children: [body] }), Box({ flexDirection: 'row', children: [Text({ color: orange, children: [legs + ' '] }), capText] })]
647}
648
649export function register(on) {
650 on('session.start', async ($, e, next) => {
651 await loadProfile($)
652 await loadSquads($)
653 try {
654 runningVersion = JSON.parse(await $.fs.read($.plugin.root + '/.claude-plugin/plugin.json')).version ?? null
655 } catch {
656 runningVersion = null
657 }
658 const skew = await installedSkew($)
659 if (skew) {
660 skewToasted = true
661 $.ui.toast('agt ⚑ ' + skewMessage(skew, runningVersion))
662 }
663 transport = await detectTransport($)
664 worker = parseWorker(await $.env.get('AGENTILLE_WORKER'))
665 sessionId = validLead(await $.session.id().catch(() => null))
666 wireName = /^agt-[A-Za-z0-9_-]+$/.test((await $.env.get('AGENTILLE_NAME')) ?? '') ? await $.env.get('AGENTILLE_NAME') : null
667 leadSession = wireName ? validLead(await $.env.get('AGENTILLE_LEAD')) : null
668 if (worker) { pub.model = worker.model; pub.effort = worker.effort || null }
669 deckAuto = (await $.store.get('deck:auto')) === true
670 await $.command.register({ name: 'agt-deck', description: 'Open the agentille deck: the cast, routing timeline, wire log and tokens. auto = open on every /agt · off', argumentHint: '[auto|off]', immediate: true })
671 await $.command.register({ name: 'agt-tell', description: 'Send a message to a worker pane over the wire', argumentHint: '<worker> <message>', immediate: true })
672 await $.command.register({ name: 'agt-routing', description: 'Show the model + effort agentille picked for each agent this session', immediate: true })
673 await $.command.register({ name: 'agt-spawn', description: 'Open a routed claude pane beside this one (Herdr or tmux); it is never reaped', argumentHint: '"task" [--model sonnet|opus|haiku|fable]' })
674 await $.command.register({ name: 'agt-ledger', description: 'Tokens per agent role for the latest agentille run', immediate: true })
675 focusOn = (await $.store.get('focus:mode')) !== 'off'
676 highlightOn = (await $.store.get('highlight:on')) !== false
677 highlightAll = (await $.store.get('highlight:all')) === true
678 await $.command.register({ name: 'agt-highlight', description: 'Highlight replies: an essentials card, paths, versions and numbers lit. on (/agt only) · all · off', argumentHint: '[on|all|off]', immediate: true })
679 await $.command.register({ name: 'agt-focus', description: 'Show agent flags above the prompt (a REVISE, a FAIL, a blocked pane). on · off', argumentHint: '[on|off]', immediate: true })
680 if (transport !== 'none') {
681 // One look now: a lead restarted mid-run sees its leftover panes. It reaps only those whose answer
682 // was written after this look; an older one is flagged "not harvested" and left to the person.
683 await pollNow($, transport)
684 if (panes.length) ensurePolling($)
685 // The lead opens and closes its workers through the mod; a worker pane gets no fan-out of its own.
686 if (!selfName && !worker) {
687 await $.tool.register(SPAWN_TOOL)
688 await $.tool.register(CLOSE_TOOL)
689 paneTools = true
690 }
691 }
692 if (working()) ensureTicker($)
693 if (wireName) await publish($, true)
694 await refreshAgents($)
695 return next(e)
696 })
697
698 on('command.run', { command: 'agt-deck' }, async ($, e) => {
699 const a = String(e.args ?? '').trim().toLowerCase()
700 if (a && a !== 'auto' && a !== 'off') return { text: 'Usage: /agt-deck [auto|off]' }
701 if (a) {
702 deckAuto = a === 'auto'
703 await $.store.set('deck:auto', deckAuto)
704 if (!deckAuto) return { text: 'deck: opens only when you type /agt-deck' }
705 }
706 const o = await $.ui.open({ id: DECK, title: 'agentille' })
707 const auto = deckAuto ? ' · opens on every /agt (/agt-deck off to stop)' : ''
708 if (o?.isPlaced === false) return { text: 'deck waits: ' + (o.reason ?? 'no surface here places panes') + auto }
709 return { text: 'deck open' + auto }
710 })
711
712 // Typed only. The worker gets it as a peer message over the wire; a Herdr worker without the
713 // mod still gets it as a prompt.
714 on('command.run', { command: 'agt-tell' }, async ($, e) => {
715 if (!['composer', 'bridge'].includes(e.origin?.kind)) return { text: '/agt-tell runs only when typed.' }
716 const t = await transportOf($)
717 if (t !== 'none') await pollNow($, t)
718 const a = parseTellArgs(e.args, panes)
719 if (a.usage || a.error) return { text: a.usage ?? a.error }
720 const rec = await $.store.get(statusKey(a.name)).catch(() => null)
721 const sid = freshStatus(rec, a.name, Date.now()) ? validLead(rec.session) : null
722 let sent = false
723 if (sid) sent = (await $.session.send({ to: { sessionId: sid }, text: '[agt wire] lead note · ' + a.text }).catch(() => ({ isDelivered: false }))).isDelivered
724 if (!sent && t === 'herdr') sent = (await $.process.run(herdrPromptArgv(a.name, a.text), PROBE).catch(() => null))?.exitCode === 0
725 if (!sent) return { text: 'Could not reach ' + a.name + '.' }
726 logWire(wireLog, { from: 'lead', to: shortName(a.name), kind: 'note', summary: a.text, at: Date.now() })
727 $.ui.invalidate('ui.render')
728 return { text: '⇄ sent to ' + a.name }
729 })
730
731 on('command.run', { command: 'agt-routing' }, async () => {
732 if (decisions.length === 0) return { text: 'No agentille dispatches this session.' }
733 return { text: decisions.slice(-30).map((d) => d.role + ' → ' + d.model + (d.effort ? ' · ' + d.effort : '') + (d.reason === 'table' ? '' : ' (' + d.reason + ')') + (d.kind === 'pane' ? ' · pane' : '')).join('\n') }
734 })
735
736 // Typed only: a plugin, a scheduled task or a notification never opens a pane.
737 on('command.run', { command: 'agt-spawn' }, async ($, e) => {
738 if (!['composer', 'bridge'].includes(e.origin?.kind)) return { text: '/agt-spawn runs only when typed.' }
739 const a = parseSpawnArgs(e.args)
740 if (a.usage || a.error) return { text: a.usage ?? a.error }
741 const t = await transportOf($)
742 if (t === 'none') return { text: 'No pane transport here: /agt-spawn needs Claude Code running inside Herdr or tmux.' }
743 const run = newRunId()
744 const name = paneName(run, SPAWN_ROLE)
745 try {
746 await openPane($, t, { run, name, model: a.model, task: a.task, cwd: await $.session.cwd() })
747 } catch (err) {
748 return { text: 'Could not open a ' + t + ' pane: ' + String(err?.message ?? err).slice(0, 160) }
749 }
750 ensurePolling($)
751 return { text: 'Opened ' + name + ' · ' + a.model + ' · ' + t + ' pane.' }
752 })
753
754 // /agt panes mode: the same openPane as /agt-spawn, with the input validated here.
755 on('tool.call', { tool: 'mcp__agentille__spawn_pane' }, async ($, e) => {
756 if (selfName || worker) return { deny: 'This is a worker pane (' + (selfName ?? worker.agent) + '); workers do not open panes.' }
757 // An update since this session started leaves it on old code: its workers would never report.
758 const skew = await installedSkew($)
759 if (skew) {
760 if (!skewToasted) $.ui.toast('agt ⚑ ' + skewMessage(skew, runningVersion))
761 skewToasted = true
762 return { deny: skewMessage(skew, runningVersion) }
763 }
764 const t = await transportOf($)
765 if (t === 'none') return { deny: 'No pane transport here: run the slice as a subagent.' }
766 const a = spawnToolInput(e, panes)
767 if (a.error) return { deny: a.error }
768 const cwd = a.cwd ?? (await $.session.cwd())
769 if (a.cwd) {
770 const s = await $.fs.stat(cwd).catch(() => null)
771 if (s?.kind !== 'dir') return { deny: cwd + ' is not a directory.' }
772 }
773 agtTurn = true
774 lastRun = a.run
775 const run = runState(a.run)
776 run.formation = formationOf(a.hdr) ?? run.formation
777 // Counted only once the pane opens: a failed split is not a fix attempt.
778 const fixes = run.fixes + (a.agent === 'executor' && a.hdr.mode === 'fix' ? 1 : 0)
779 run.fable = Math.max(run.fable, Number((await $.store.get('fable:' + a.run)) ?? 0))
780 const d = decide({ role: a.agent, hdr: a.hdr, run: { ...run, fixes }, depth, settings, weeklyPct })
781 const r = paneRole(a.agent)
782 if (!r.pane) return { deny: r.why }
783 await pollNow($, t)
784 opened = opened.filter((o) => panes.some((p) => p.id === o.id))
785 const lead = t === 'tmux' ? await $.env.get('TMUX_PANE') : await $.env.get('HERDR_PANE_ID')
786 const plan = splitPlan({ lead, opened, live: panes })
787 let id
788 try {
789 id = await openPane($, t, { run: a.run, name: a.name, agent: a.agent, model: d.model, effort: d.effort, task: a.task, cwd, split: { target: plan.target, direction: plan.direction } })
790 } catch (err) {
791 return { deny: 'Could not open a ' + t + ' pane: ' + String(err?.message ?? err).slice(0, 160) }
792 }
793 // A role can be respawned (a fix, a retry), and the multiplexer can hand the old pane's id out again. Harvest state is
794 // keyed by pane id and the list call above may have failed, so wipe whatever the id carries: the new pane starts with
795 // nothing harvested and only files written from now on count as its own.
796 firstSeen.set(id, await $.clock.now())
797 for (const s of [harvested, wireDone, woken, strandedToasted]) s.delete(id)
798 paneSeen.delete(id)
799 run.fixes = fixes
800 if (d.fable) {
801 run.fable += 1
802 await $.store.set('fable:' + a.run, run.fable)
803 }
804 opened.push({ id, name: a.name })
805 paneRoutes.push({ name: a.name, run: a.run, role: a.role, agent: a.agent, model: d.model, effort: d.effort, reason: d.reason, start: Date.now(), end: null })
806 const rec = { at: new Date().toISOString(), role: a.agent, model: d.model, effort: d.effort, reason: d.reason, asked: a.asked, agentId: null, kind: 'pane', pane: a.name }
807 decisions.push(rec)
808 run.log.push(JSON.stringify(rec))
809 await writeRunFile($, a.run, 'routing.jsonl', run.log.join('\n') + '\n')
810 await pollNow($, t)
811 ensurePolling($)
812 ensureTicker($)
813 if (d.reason !== 'table') $.ui.toast('agt ↑ ' + a.agent + ' → ' + d.model + ' · ' + d.effort + ' — ' + d.reason)
814 return { result: 'Opened ' + a.name + ' · ' + d.model + ' · ' + d.effort + ' · ' + t + ' pane.' + (d.reason === 'table' ? '' : ' (' + d.reason + ')') }
815 })
816
817 on('tool.call', { tool: 'mcp__agentille__close_pane' }, async ($, e) => {
818 if (selfName || worker) return { deny: 'This is a worker pane (' + (selfName ?? worker.agent) + '); workers do not close panes.' }
819 const t = await transportOf($)
820 if (t === 'none') return { deny: 'No pane transport here.' }
821 await pollNow($, t)
822 const c = closeTarget(e, panes, t === 'herdr' ? selfTab : null)
823 if (c.error) return { deny: c.error }
824 const r = await $.process.run(t === 'herdr' ? herdrCloseArgv(c.pane.id) : tmuxKillArgv(c.pane.id), PROBE).catch(() => null)
825 if (!r || r.exitCode !== 0) return { deny: 'Could not close ' + c.pane.name + '.' }
826 harvested.add(c.pane.id)
827 await pollNow($, t)
828 return { result: 'Closed ' + c.pane.name + '.' }
829 })
830
831 on('command.run', { command: 'agt-focus' }, async ($, e) => {
832 const a = parseFocusArgs(e.args)
833 if (a.error) return { text: a.error }
834 if (a.on !== undefined) {
835 focusOn = a.on
836 await $.store.set('focus:mode', a.on ? 'on' : 'off')
837 $.ui.invalidate('ui.render')
838 }
839 return { text: focusText(focusOn, focusLines(Date.now())) }
840 })
841
842 on('command.run', { command: 'agt-highlight' }, async ($, e) => {
843 const a = parseHighlightArgs(e.args)
844 if (a.error) return { text: a.error }
845 if (a.on !== undefined) {
846 highlightOn = a.on
847 highlightAll = a.all
848 await $.store.set('highlight:on', a.on)
849 await $.store.set('highlight:all', a.all)
850 $.ui.invalidate('ui.render')
851 }
852 return { text: highlightText(highlightOn, highlightAll) }
853 })
854
855 on('command.run', { command: 'agt-ledger' }, async () => ({ text: ledgerText(ledger(live, lastRun, paneRoutes)) }))
856
857 // A typed /agt starts a clean run; a task notification, plugin or peer turn is not the person's prompt.
858 on('prompt.submit', async ($, e, next) => {
859 const k = e.origin?.kind
860 if (k !== undefined && k !== 'composer' && k !== 'bridge') return next(e)
861 agtTurn = isAgtPrompt(e.text)
862 if (agtTurn) flags = [] // a new run starts with a clean slate
863 if (agtTurn && deckAuto) void $.ui.open({ id: DECK, title: 'agentille' })
864 return next(e)
865 })
866
867 on('skill.prompt', async ($, e, next) => {
868 if (!/(^|:)agt$/.test(e.skill)) return next(e)
869 agtTurn = true
870 const t = await transportOf($)
871 if (t !== 'none') ensurePolling($)
872 return next({ ...e, text: e.text + squadBlock + transportBlock(t, paneTools && !selfName) })
873 })
874
875 // Worker results arrive as peer messages: log them for the band and the deck, then let the
876 // model read them (that delivery is what wakes the lead).
877 on('session.receive', async ($, e, next) => {
878 const w = parseWire(e.text)
879 if (w) {
880 const from = shortName(w.from)
881 // Only the pane whose key the message names: a stale list or a respawned role must not harvest the older pane.
882 const hit = w.kind === 'done' && w.key ? panes.find((p) => paneKey(p.id) === w.key && p.name === w.from) : null
883 if (hit) { harvested.add(hit.id); wireDone.add(hit.id) }
884 logWire(wireLog, { from, to: wireName ? shortName(wireName) : 'lead', kind: w.kind, summary: w.summary, at: Date.now() })
885 $.ui.toast('⇄ ' + from + ' ' + w.kind)
886 $.ui.invalidate('ui.render')
887 }
888 return next(e)
889 })
890
891 // What each agent is doing right now, for its band row: a subagent's call carries its agentId;
892 // a worker pane's own main loop publishes for its lead. Observe only.
893 on('tool.call', async ($, e, next) => {
894 if (e.agentId) {
895 const a = live.get(e.agentId)
896 if (a) {
897 a.tool = toolLabel(e.tool, e)
898 a.heard = Date.now()
899 }
900 // A workflow agent inside one long call (a full test suite) is alive: tree.js quietWorkflows
901 // skips it until the call returns, and the return counts as heard.
902 if (a?.workflow) {
903 a.calls = (a.calls ?? 0) + 1
904 try {
905 return await next(e)
906 } finally {
907 a.calls -= 1
908 a.heard = Date.now()
909 }
910 }
911 } else if (wireName) {
912 pub.tool = toolLabel(e.tool, e)
913 void publish($).catch(() => {})
914 }
915 return next(e)
916 })
917
918 on('session.measure', async ($, e, next) => {
919 const week = e.rateLimits.find((r) => r.kind === 'seven_day')
920 if (week) weeklyPct = week.percentUsed
921 return next(e)
922 })
923
924 on('agent.spawn', async ($, e, next) => {
925 if (e.fork) return next(e)
926 const role = roleOf(e.subagentType)
927
928 if (!role) {
929 const res = await next(e)
930 if (res.agentId) live.set(res.agentId, heard(newAgent({ id: res.agentId, role: shortType(e.subagentType), routed: false, model: res.model, effort: null, reason: null, run: lastRun, now: Date.now(), parentId: e.parentAgentId ?? null }), e))
931 ensureTicker($)
932 $.ui.invalidate('ui.render')
933 return res
934 }
935
936 const hdr = parseHeader(e.prompt) ?? {}
937 // A workflow script's agent: the engine ignores a rewrite there (and logs a failure line), so
938 // it runs on the model the script chose. Log that model, not the table's, and flag a mismatch.
939 if (e.workflow) {
940 // Its prompt may carry no header: it then joins the run in progress rather than reset it. A header
941 // names its run, but the band moves to it only when the current run has nothing working, subagent or pane.
942 const named = SAFE_RUN.test(hdr.run ?? '') ? hdr.run : null
943 const runId = named ?? lastRun
944 // Its files and counters follow the header: a headerless agent files under 'adhoc' (which writes
945 // nothing), so the run in progress keeps only its own routing log and ledger.
946 const fileRun = named ?? 'adhoc'
947 const run = runState(fileRun)
948 const busy = [...live.values()].some((a) => a.run === lastRun && a.state === 'working') || panes.some((p) => splitName(p.name)?.run === lastRun && p.state === 'working')
949 if (named && (lastRun === 'adhoc' || !busy)) lastRun = named
950 agtTurn = true
951 // The table pick follows the run in progress (its REVISEs); `run` is only where the log goes.
952 const d = decide({ role, hdr, run: runState(runId), depth, settings, weeklyPct })
953 const res = await next(e)
954 if (res.deny) return res
955 const off = short(res.model) !== short(d.model)
956 const drift = off ? 'workflow, table says ' + d.model + ' · ' + d.effort : 'workflow'
957 if (res.agentId) {
958 const a = heard(newAgent({ id: res.agentId, role, routed: false, model: res.model, effort: null, reason: off ? drift : null, run: runId, now: Date.now(), parentId: e.parentAgentId ?? null }), e)
959 a.fileRun = fileRun
960 live.set(res.agentId, a)
961 }
962 const rec = { at: new Date().toISOString(), role, model: res.model, effort: null, reason: drift, asked: e.model ?? null, agentId: res.agentId ?? null, kind: 'workflow' }
963 decisions.push(rec)
964 run.log.push(JSON.stringify(rec))
965 await writeRunFile($, fileRun, 'routing.jsonl', run.log.join('\n') + '\n')
966 ensureTicker($)
967 if (off && !wfToasted.has(runId + ':' + role)) {
968 wfToasted.add(runId + ':' + role)
969 $.ui.toast('agt workflow ' + role + ' runs ' + short(res.model) + ' — table says ' + d.model + ' · ' + d.effort)
970 }
971 $.ui.invalidate('ui.render')
972 return res
973 }
974
975 const runId = SAFE_RUN.test(hdr.run ?? '') ? hdr.run : 'adhoc'
976 agtTurn = true
977 lastRun = runId
978 const run = runState(runId)
979 run.formation = formationOf(hdr) ?? run.formation
980 if (role === 'executor' && hdr.mode === 'fix') run.fixes += 1
981 const shared = Number((await $.store.get('fable:' + runId)) ?? 0)
982 run.fable = Math.max(run.fable, shared)
983
984 const d = decide({ role, hdr, run, depth, settings, weeklyPct })
985 const res = await next({ ...e, model: d.model })
986 if (res.deny) return res
987
988 if (d.fable) {
989 run.fable += 1
990 await $.store.set('fable:' + runId, run.fable)
991 }
992 if (res.agentId) live.set(res.agentId, newAgent({ id: res.agentId, role, routed: true, model: res.model, effort: d.effort, reason: d.reason, run: runId, now: Date.now(), parentId: e.parentAgentId ?? null }))
993 const rec = { at: new Date().toISOString(), role, model: res.model, effort: d.effort, reason: d.reason, asked: e.model ?? null, agentId: res.agentId ?? null }
994 decisions.push(rec)
995 run.log.push(JSON.stringify(rec))
996 await writeRunFile($, runId, 'routing.jsonl', run.log.join('\n') + '\n')
997 ensureTicker($)
998 if (d.reason !== 'table') $.ui.toast('agt ↑ ' + role + ' → ' + short(res.model) + ' · ' + d.effort + ' — ' + d.reason)
999 $.ui.invalidate('ui.render')
1000 return res
1001 })
1002
1003 on('turn.step', async function* ($, e, next) {
1004 const a = e.agentId ? live.get(e.agentId) : undefined
1005 if (!e.agentId) leadModel = e.model
1006 const result = yield* next(a && a.routed ? { ...e, effort: a.effort } : e)
1007 if (!e.agentId && wireName && result?.usage) {
1008 const u = result.usage
1009 pub.tok += (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.output_tokens ?? 0)
1010 pub.model = e.model
1011 await publish($)
1012 }
1013 if (a) {
1014 a.heard = Date.now()
1015 if (!a.effort && e.effort) a.effort = String(e.effort)
1016 if (!a.model && e.model) a.model = e.model
1017 addUsage(a, result?.usage)
1018 $.ui.invalidate('ui.render')
1019 }
1020 return result
1021 })
1022
1023 // A main-loop turn began (a subagent's run raises none). A worker's first one plays hello.
1024 on('turn.start', async ($, e, next) => {
1025 leadTurn = true
1026 if (worker) {
1027 const now = await $.clock.now()
1028 if (!mascot.greeted) {
1029 mascot.greeted = true
1030 mascot.hiUntil = now + HELLO_MS
1031 // tmux tags the pane name after the split, so the first poll can miss it: look once more.
1032 if (!selfName && transport && transport !== 'none') await pollNow($, transport)
1033 }
1034 mascot.working = true
1035 mascot.start = now
1036 ensureTicker($)
1037 $.ui.invalidate('ui.render')
1038 }
1039 if (wireName) {
1040 Object.assign(pub, { state: 'working', tool: null, start: Date.now(), ms: null })
1041 await publish($, true)
1042 }
1043 return next(e)
1044 })
1045
1046 on('turn.complete', async ($, e, next) => {
1047 const a = e.agentId ? live.get(e.agentId) : undefined
1048 if (!e.agentId) leadTurn = false
1049 if (!e.agentId && worker && mascot.working) {
1050 const now = await $.clock.now()
1051 mascot.working = false
1052 mascot.ms = now - mascot.start
1053 if (e.reason === 'answer') mascot.byeUntil = now + BYE_MS
1054 ensureTicker($)
1055 $.ui.invalidate('ui.render')
1056 }
1057 if (!e.agentId && wireName && pub.state === 'working') {
1058 Object.assign(pub, { state: 'done', tool: null, ms: Date.now() - (pub.start ?? Date.now()) })
1059 await publish($, true)
1060 if (e.reason === 'answer') await reportDone($, e.answer)
1061 } else if (!e.agentId && !wireName && selfName && e.reason === 'answer') {
1062 // A lead that gave this pane no wire identity (an older agentille, a hand-made pane) never hears from
1063 // it; the saved answer is all it can leave, and what the lead reads before closing the pane.
1064 await saveAnswer($, selfName, e.answer)
1065 }
1066 if (a) {
1067 a.tool = null
1068 if (a.routed) addFlag($, flagOf(a.role, e.answer))
1069 if (a.role === 'plan-reviewer' && verdictOf(e.answer) === 'REVISE') runState(fileRunOf(a)).revise += 1
1070 if (a.input + a.output === 0) addUsage(a, e.usage)
1071 finish(a, Date.now(), e.durationMs)
1072 await writeReport($, a, e.answer)
1073 await writeRunFile($, fileRunOf(a), 'ledger.json', JSON.stringify(ledger(live, fileRunOf(a), paneRoutes), null, 2) + '\n')
1074 ensureTicker($)
1075 void refreshAgents($)
1076 $.ui.invalidate('ui.render')
1077 }
1078 return next(e)
1079 })
1080
1081 // The switchboard above the prompt: a framed dispatch tree rooted at the lead, one row per
1082 // subagent (◇) and pane session (▣), the newest wire message, and any agent flags.
1083 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1084 if (e.props.hasSurvey) return next(e)
1085 const now = Date.now()
1086 const staged = stage({ agents: live, panes, routes: paneRoutes, run: lastRun, now })
1087 const focus = focusLines(now)
1088 const wire = wireRow(wireLog, now)
1089 if (staged.rows.length === 0 && !selfName && !worker && focus.length === 0 && !wire) return next(e)
1090 const els = $.ui.resolve(e)
1091 const kids = focus.map((l) => focusRow(els, l))
1092 if (worker) kids.push(...workerBand(els, e, await $.clock.now()))
1093 else if (selfName) kids.push(els.Text({ color: hex(MODEL_COLOR.opus), children: ['agentille worker · ' + selfName] }))
1094 const theirs = await next(e)
1095 // No agent rows: the newest wire message alone, no frame.
1096 if (staged.rows.length === 0) return els.Box({ flexDirection: 'column', children: [...kids, ...(wire ? [wireLine(els, wire)] : []), theirs] })
1097
1098 const rows = nest(cast(staged, live, lastRun))
1099 const cols = (e.props.bodyColumns ?? 80) - 4
1100 const view = boardRows(rows, { now, tick, cols, wire: wireStatus, transport, view: e.props.view?.agentId ?? null })
1101 const sw = swarm({ agents: live, routes: paneRoutes, run: lastRun, tick })
1102 const room = Math.max(1, (e.props.maxRows ?? 10) - 4 - kids.length - (wire ? 1 : 0) - (sw ? 1 : 0))
1103 const waiting = rows.filter((r) => r.state === 'working').length
1104 const inner = [boardHeader(els, header({ run: lastRun, formation: runs.get(lastRun)?.formation, squads: squads.map((q) => q.name), rows, tally: staged.tally }))]
1105 if (sw) inner.push(swarmLine(els, sw))
1106 if (rows.length) inner.push(leadRow(els, leadLine({ model: leadModel, busy: e.props.isWorking, waiting })))
1107 const shown = view.slice(0, room)
1108 const argvs = shown.map((r) => (r.pane ? focusArgv(transport, rows.find((x) => x.id === r.id)) : null))
1109 const slot = argvs.some(Boolean)
1110 shown.forEach((r, i) => inner.push(boardRow(els, r, argvs[i] ? () => { $.process.run(argvs[i], PROBE).catch(() => {}) } : null, slot)))
1111 if (view.length > room) inner.push(els.Text({ dimColor: true, children: [' +' + (view.length - room) + ' more · /agt-deck'] }))
1112 if (wire) inner.push(wireLine(els, wire))
1113 const board = els.Box({ flexDirection: 'column', borderStyle: 'round', borderColor: hex(FRAME_COLOR), paddingX: 1, children: inner })
1114 return els.Box({ flexDirection: 'column', children: [...kids, board, theirs] })
1115 })
1116
1117 // The deck: the whole run at a glance, drawn only while the person keeps it open.
1118 on('ui.render', { component: 'Pane', requestId: DECK }, async ($, e) => {
1119 const els = $.ui.resolve(e)
1120 const { Box, Text } = els
1121 const now = Date.now()
1122 const staged = stage({ agents: live, panes, routes: paneRoutes, run: lastRun, now })
1123 const all = [...staged.rows, ...[...live.values()].filter((a) => a.run === lastRun && !staged.rows.includes(a))]
1124 const rows = cast({ rows: all.map((r) => (r.kind === 'pane' && !r.model ? { ...r, model: wireStatus.get(r.name)?.model ?? null } : r)), tally: { working: 0 } }, live, lastRun)
1125 const cols = e.props.bodyColumns ?? 80
1126 const kids = [boardHeader(els, header({ run: lastRun, formation: runs.get(lastRun)?.formation, squads: squads.map((q) => q.name), rows, tally: staged.tally }))]
1127 if (rows.length === 0 && decisions.length === 0 && wireLog.length === 0) {
1128 kids.push(Text({ dimColor: true, children: ['No agents yet. Type /agt "task": every dispatch shows up here.'] }))
1129 return Box({ flexDirection: 'column', children: kids })
1130 }
1131 if (rows.length) {
1132 kids.push(rule(els, 'cast'))
1133 const c = castColumns(rows, { cols, tick: tick >> 1, frameOf: frame, moodOf: (r) => (r.kind === 'sub' ? agentMood(r, now) : r.state === 'working' ? 'working' : 'bye') })
1134 const orange = hex(MASCOT_COLOR)
1135 const line = (pick) => Box({ flexDirection: 'row', children: c.cells.map(pick) })
1136 for (const i of [0, 1, 2]) kids.push(line((m) => Text({ color: orange, dimColor: m.dim, children: [m.lines[i]] })))
1137 kids.push(line((m) => Text({ dimColor: m.dim, children: [(m.glyph + ' ' + m.role).padEnd(14)] })))
1138 kids.push(line((m) => Box({ width: 14, children: [m.model ? Text({ color: INK, backgroundColor: colorOf(m.model), children: [chip(m.model)] }) : Text({ dimColor: true, children: ['pane'] })] })))
1139 if (c.more > 0) kids.push(Text({ dimColor: true, children: ['+' + c.more + ' more'] }))
1140 }
1141 if (decisions.length) {
1142 kids.push(rule(els, 'routing'))
1143 for (const d of routingLines(decisions)) kids.push(Text({ wrap: 'truncate', children: [Text({ dimColor: true, children: [d.at + ' '] }), d.who + ' ', Text({ color: d.color, children: [d.route] }), Text({ color: d.escalated ? colorOf('fable') : d.drift ? 'warning' : undefined, dimColor: !d.escalated && !d.drift, children: [d.reason] })] }))
1144 }
1145 if (wireLog.length) {
1146 kids.push(rule(els, 'wire'))
1147 for (const w of wireLines(wireLog)) kids.push(Text({ wrap: 'truncate', children: [Text({ dimColor: true, children: [w.at + ' '] }), Text({ color: hex(WIRE_COLOR), children: [w.arrow + ' '] }), Text({ dimColor: true, children: [w.kind + ' '] }), w.text] }))
1148 }
1149 const bars = tokenBars(ledger(live, lastRun, paneRoutes), Math.max(8, Math.min(30, cols - 30)))
1150 if (bars.length) {
1151 kids.push(rule(els, 'tokens'))
1152 for (const b of bars) kids.push(Text({ children: [b.name + ' ', Text({ color: hex(ACCENT), children: [b.bar] }), Text({ dimColor: true, children: [' ' + b.tok] })] }))
1153 }
1154 return Box({ flexDirection: 'column', children: kids })
1155 })
1156
1157 // A long /agt reply: an essentials card on top, the body dim with its tokens lit. The
1158 // decision is memoised per message id so a reply does not restyle when a later turn changes.
1159 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
1160 if (!litFor(litMemo, e.requestId, { on: highlightOn, agtTurn: agtTurn || highlightAll })) return next(e)
1161 const h = highlightFor(hlMemo, e.requestId, e.props?.text)
1162 if (!h) return next(e)
1163 const els = $.ui.resolve(e)
1164 if (!els.Markdown) return next(e)
1165 const body = h.body !== null ? els.Markdown({ text: h.body, dimColor: true }) : await next(e)
1166 return els.Box({ flexDirection: 'column', children: h.card.length ? [cardBox(els, h.card), body] : [body] })
1167 })
1168
1169 // Main-session spinner: how many subagents (◇) and pane sessions (▣) work behind it.
1170 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
1171 if (live.has(e.requestId)) return next(e)
1172 const subs = [...live.values()].filter((a) => a.state === 'working').length
1173 const sessions = panes.filter((p) => p.state === 'working').length
1174 if (subs + sessions === 0) return next(e)
1175 const parts = [subs ? '◇' + subs : '', sessions ? '▣' + sessions : ''].filter(Boolean).join(' ')
1176 return next({ ...e, props: { ...e.props, suffix: (e.props.suffix ?? '') + ' · ' + parts + ' working' } })
1177 })
1178
1179 registerRows(on, ctx)
1180 registerStatus(on, ctx)
1181 registerAutocomplete(on, ctx)
1182}
1183hooks/routing.js 103 lines1// agentille routing policy — pure functions, no mods API.
2// register.js feeds it what the mod observed; it returns { model, effort, reason }.
3// The table and ladder mirror skills/agt/routing.md → "Default routing" (that doc is the spec).
4
5const ROLE_PREFIX = 'agentille:agentille-'
6
7// [model, effort] per role: default · size=large · risk auth/money · thinkingDepth=quick
8const TABLE = {
9 planner: { base: ['opus', 'high'], large: ['opus', 'xhigh'], risk: null, quick: ['sonnet', 'medium'] },
10 'plan-reviewer': { base: ['sonnet', 'medium'], large: ['opus', 'high'], risk: null, quick: ['sonnet', 'medium'] },
11 'ui-prototyper': { base: ['opus', 'high'], large: ['opus', 'high'], risk: null, quick: ['sonnet', 'medium'] },
12 executor: { base: ['sonnet', 'medium'], large: ['sonnet', 'high'], risk: ['sonnet', 'high'], quick: ['sonnet', 'medium'] },
13 'code-reviewer': { base: ['sonnet', 'medium'], large: ['opus', 'high'], risk: ['opus', 'high'], quick: ['sonnet', 'medium'] },
14 'design-reviewer': { base: ['opus', 'high'], large: ['opus', 'high'], risk: ['opus', 'high'], quick: ['opus', 'high'] },
15 'security-reviewer': { base: ['opus', 'high'], large: ['opus', 'high'], risk: ['opus', 'max'], quick: ['sonnet', 'high'] },
16 'payments-reviewer': { base: ['opus', 'high'], large: ['opus', 'high'], risk: ['opus', 'max'], quick: ['sonnet', 'high'] },
17 'seo-reviewer': { base: ['sonnet', 'medium'], large: ['sonnet', 'high'], risk: null, quick: ['sonnet', 'low'] },
18 'perf-reviewer': { base: ['sonnet', 'high'], large: ['opus', 'high'], risk: null, quick: ['sonnet', 'medium'] },
19 adversary: { base: ['sonnet', 'high'], large: ['sonnet', 'high'], risk: ['opus', 'high'], quick: ['sonnet', 'medium'] },
20}
21
22export const ROLES = Object.keys(TABLE)
23
24// Roles that may run on Fable. The executor never changes model.
25const FABLE_ROLES = new Set(['planner', 'ui-prototyper', 'design-reviewer', 'security-reviewer', 'payments-reviewer', 'code-reviewer', 'plan-reviewer'])
26const FORCED_FABLE_NEEDS_LARGE = new Set(['code-reviewer', 'plan-reviewer'])
27
28// Run shapes beyond the plain roster (skills/agt/formations.md); shown in the band header.
29export const FORMATIONS = new Set(['duel', 'gauntlet', 'relay'])
30
31export function formationOf(hdr) {
32 return hdr && FORMATIONS.has(hdr.formation) ? hdr.formation : null
33}
34
35export const DEFAULTS = { autoFable: true, maxFablePerRun: 1, fableWeeklyCeiling: 60, advisorOnOpus: true }
36
37export function roleOf(subagentType) {
38 if (typeof subagentType !== 'string' || !subagentType.startsWith(ROLE_PREFIX)) return null
39 const role = subagentType.slice(ROLE_PREFIX.length)
40 return TABLE[role] ? role : null
41}
42
43// "[agt run=a1b2 size=large risk=auth mode=fix fable=auto]" on the prompt's first line
44export function parseHeader(prompt) {
45 const first = String(prompt ?? '').split('\n', 1)[0]
46 const m = /^\s*\[agt\s+([^\]]*)\]/.exec(first)
47 if (!m) return null
48 const h = {}
49 for (const pair of m[1].trim().split(/\s+/)) {
50 const i = pair.indexOf('=')
51 if (i > 0) h[pair.slice(0, i)] = pair.slice(i + 1)
52 }
53 return h
54}
55
56// First verdict word in a plan-reviewer answer, or null.
57export function verdictOf(answer) {
58 const m = /\b(APPROVE|REVISE)\b/.exec(String(answer ?? ''))
59 return m ? m[1] : null
60}
61
62function base(role, hdr, depth) {
63 const row = TABLE[role]
64 if (depth === 'quick') return row.quick
65 if ((hdr.risk === 'auth' || hdr.risk === 'money') && row.risk) return row.risk
66 if (hdr.size === 'large') return row.large
67 return row.base
68}
69
70// run: { revise, fixes, fable } observed so far · settings: profile.routing merged over DEFAULTS
71// weeklyPct: seven_day percent used, or null when unknown
72export function decide({ role, hdr = {}, run = { revise: 0, fixes: 0, fable: 0 }, depth, settings = DEFAULTS, weeklyPct = null }) {
73 let [model, effort] = base(role, hdr, depth)
74 const reasons = []
75
76 if (hdr.fable === 'forced' && FABLE_ROLES.has(role) && (!FORCED_FABLE_NEEDS_LARGE.has(role) || model === 'opus')) {
77 return { model: 'fable', effort: 'high', reason: '--fable typed', fable: true }
78 }
79
80 // Ladder rung 1–2: effort first, on observed failure.
81 if (role === 'planner' && run.revise === 1) { effort = 'max'; reasons.push('plan REVISE ×1') }
82 if (role === 'executor' && hdr.mode === 'fix') {
83 const attempt = run.fixes // this dispatch already counted
84 if (attempt === 2) { effort = 'high'; reasons.push('fix attempt 2') }
85 if (attempt >= 3) { effort = 'max'; reasons.push('fix attempt ' + attempt) }
86 }
87
88 // Ladder rung 3: Fable, only after two observed failures at Opus max.
89 const candidate =
90 (role === 'planner' && run.revise >= 2 && hdr.mode !== 'diagnose') ? 'plan REVISE ×2' :
91 (role === 'planner' && hdr.mode === 'diagnose' && run.fixes >= 3) ? run.fixes + ' failed fixes' :
92 null
93 if (!candidate) return { model, effort, reason: reasons.join(', ') || 'table', fable: false }
94
95 const block =
96 settings.autoFable === false ? 'autoFable off' :
97 run.fable >= settings.maxFablePerRun ? 'Fable cap ' + settings.maxFablePerRun + '/run reached' :
98 (weeklyPct != null && weeklyPct >= settings.fableWeeklyCeiling) ? 'weekly ' + weeklyPct + '% ≥ ' + settings.fableWeeklyCeiling + '%' :
99 null
100 if (block) return { model: 'opus', effort: 'max', reason: candidate + ' · Fable blocked: ' + block, fable: false }
101 return { model: 'fable', effort: 'high', reason: candidate, fable: true }
102}
103hooks/live.js 169 lines1// What is running right now: in-process subagents and herdr agt-* panes.
2// Pure state + formatting; register.js feeds it events and draws the result.
3
4import { BYE_MS, modelKey } from './mascot.js'
5
6export const DONE_GRACE_MS = 90_000
7export const IDLE_GRACE_MS = 300_000
8export const ON_STAGE = new Set(['working', 'blocked'])
9const LEAVING = new Set(['done', 'idle'])
10const EFFORT_BAR = { low: '▂', medium: '▄', high: '▆', xhigh: '▇', max: '█' }
11
12export function newAgent({ id, role, routed, model, effort, reason, run, now, parentId = null }) {
13 return { id, kind: 'sub', role, routed, model, effort, reason, run, parentId, listStatus: null, start: now, end: null, leftAt: null, state: 'working', input: 0, output: 0, cacheRead: 0 }
14}
15
16export function addUsage(a, u) {
17 if (!a || !u) return
18 a.input += (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
19 a.output += u.output_tokens ?? 0
20 a.cacheRead += u.cache_read_input_tokens ?? 0
21}
22
23export function finish(a, now, ms) {
24 if (!a) return
25 a.state = 'done'
26 a.end = ms != null ? a.start + ms : now
27 a.leftAt = now
28}
29
30// herdr `agent list` rows → pane agents; `name` = agt-<run>-<role…>
31export function paneAgents(list, selfPane) {
32 return (list ?? [])
33 .filter((p) => typeof p.name === 'string' && p.name.startsWith('agt-') && p.pane_id !== selfPane)
34 .map((p) => {
35 const parts = p.name.split('-')
36 return { id: p.pane_id, kind: 'pane', name: p.name, role: parts.slice(2).join('-') || p.name, run: parts[1] ?? '', vendor: p.agent ?? 'agent', state: p.agent_status ?? 'unknown', seq: p.state_change_seq ?? 0, tab: p.tab_id ?? null, workspace: p.workspace_id ?? null }
37 })
38}
39
40// Remembers when each pane entered its current state (by state_change_seq) and sorts the panes
41// that sat in done ≥ 90s, or in idle ≥ 300s while the lead has no turn running (an idle worker
42// mid-run is waiting for review, not abandoned). A due pane is only `reap` once `harvested(pane)`
43// says its answer is in hand; otherwise it is `stranded`: kept open and flagged, since closing
44// it would lose the output. With no `harvested` nothing is ever reaped. Only agt-* panes reach here.
45export function reapPlan(panes, seen, now, leadBusy = true, harvested = () => false) {
46 const reap = []
47 const stranded = []
48 for (const p of panes) {
49 const key = p.id
50 const prev = seen.get(key)
51 if (!prev || prev.seq !== p.seq || prev.state !== p.state) seen.set(key, { seq: p.seq, state: p.state, since: now })
52 const since = seen.get(key).since
53 const due = (p.state === 'done' && now - since >= DONE_GRACE_MS) || (p.state === 'idle' && !leadBusy && now - since >= IDLE_GRACE_MS)
54 if (due) (harvested(p) ? reap : stranded).push(p)
55 }
56 for (const key of [...seen.keys()]) if (!panes.some((p) => p.id === key)) seen.delete(key)
57 return { reap, stranded }
58}
59
60export function short(model) {
61 return modelKey(model)
62}
63
64export function tokens(n) {
65 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
66 if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
67 return String(n)
68}
69
70export function elapsed(ms) {
71 const s = Math.max(0, Math.round(ms / 1000))
72 return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
73}
74
75export function effortBar(effort) {
76 return EFFORT_BAR[effort] ?? ' '
77}
78
79export function ledger(agents, run, routes = []) {
80 const roles = {}
81 const panes = {}
82 const total = { agents: 0, input: 0, output: 0, cacheRead: 0, ms: 0 }
83 for (const a of agents.values()) {
84 if ((a.fileRun ?? a.run) !== run) continue // a headerless workflow agent counts under its file run (ad hoc), not the band's
85 const r = (roles[a.role] ??= { agents: 0, input: 0, output: 0, cacheRead: 0, ms: 0 })
86 const ms = (a.end ?? a.start) - a.start
87 for (const t of [r, total]) {
88 t.agents += 1; t.input += a.input; t.output += a.output; t.cacheRead += a.cacheRead; t.ms += ms
89 }
90 }
91 for (const r of routes) {
92 if (r.run !== run) continue
93 const t = (panes[r.agent ?? r.role] ??= { agents: 0, ms: 0 })
94 t.agents += 1
95 t.ms += (r.end ?? r.start) - r.start
96 }
97 return { run, roles, total, panes }
98}
99
100export function ledgerText(l) {
101 const names = Object.keys(l.roles)
102 const panes = l.panes ?? {}
103 const paneNames = Object.keys(panes)
104 if (names.length === 0 && paneNames.length === 0) return 'No agents in this run yet.'
105 const line = (name, t) => name.padEnd(20) + String(t.agents).padStart(3) + ' in ' + tokens(t.input).padStart(7) + ' out ' + tokens(t.output).padStart(7) + ' cache ' + tokens(t.cacheRead).padStart(7) + ' ' + elapsed(t.ms)
106 const paneLine = (name, t) => (name + ' (pane)').padEnd(20) + String(t.agents).padStart(3) + ' tokens n/a ' + elapsed(t.ms)
107 return ['run ' + l.run, ...names.sort().map((n) => line(n, l.roles[n])), ...paneNames.sort().map((n) => paneLine(n, panes[n])), line('total', l.total)].join('\n')
108}
109
110// ── stage: who is on screen ──────────────────────────────────────────────────
111
112// Names of the panes that left the stage this call: they finished, went idle or vanished.
113// Unknown/open panes are unreadable, so they keep their slot. `staged` maps name → last pane.
114export function panesLeft(staged, next) {
115 const out = []
116 for (const [name, last] of [...staged]) {
117 const q = next.find((p) => p.name === name)
118 if (q && !LEAVING.has(q.state)) continue
119 staged.delete(name)
120 out.push(name)
121 }
122 for (const q of next) if (ON_STAGE.has(q.state)) staged.set(q.name, q)
123 return out
124}
125
126function newestRoute(routes, name, open = false) {
127 for (let i = routes.length - 1; i >= 0; i--) {
128 const r = routes[i]
129 if (r.name === name && (!open || r.end == null)) return r
130 }
131 return null
132}
133
134export function withRoute(pane, routes = []) {
135 const r = newestRoute(routes, pane.name)
136 return { ...pane, agent: r?.agent ?? null, model: r?.model ?? null, effort: r?.effort ?? null, reason: r?.reason ?? null, start: r?.start ?? null }
137}
138
139export function endRoute(routes, name, now) {
140 const r = newestRoute(routes, name, true)
141 if (!r) return null
142 r.end = now
143 return r
144}
145
146// With `now`, a subagent that finished less than BYE_MS ago stays on stage to wave bye.
147// leftAt is wall time; end can sit earlier when the harness reports a shorter duration.
148export function waving(a, now) {
149 return now != null && a.state === 'done' && a.leftAt != null && now - a.leftAt < BYE_MS
150}
151
152export function stage({ agents, panes = [], routes = [], run, now = null }) {
153 const subs = [...agents.values()].filter((a) => a.run === run)
154 const working = subs.filter((a) => a.state === 'working').sort((a, b) => a.start - b.start)
155 const byes = subs.filter((a) => waving(a, now)).sort((a, b) => a.start - b.start)
156 const rows = [...working, ...byes, ...panes.filter((p) => ON_STAGE.has(p.state)).map((p) => withRoute(p, routes))]
157 const tally = {
158 working: working.length + panes.filter((p) => p.state === 'working').length,
159 done: subs.filter((a) => a.state === 'done').length + routes.filter((r) => r.run === run && r.end != null).length,
160 tok: subs.reduce((s, a) => s + a.input + a.output, 0),
161 }
162 return { rows, tally }
163}
164
165// A typed /agt run (bare or plugin-qualified), never /agt-ledger, …
166export function isAgtPrompt(text) {
167 return /^\s*\/(agentille:)?agt(\s|$)/.test(String(text ?? ''))
168}
169hooks/board.js 272 lines1// The switchboard: what the band above the prompt shows, as plain data. Pure on purpose:
2// register.js resolves the elements and paints; this file picks rows, glyphs and columns.
3
4import { MODEL_COLOR, modelKey } from './mascot.js'
5import { effortBar, elapsed, tokens } from './live.js'
6
7export const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
8export const KIND_GLYPH = { sub: '◇', pane: '▣' }
9export const DONE_GLYPH = '✓'
10export const ACCENT = 0xd97757
11export const FRAME_COLOR = 0x3d4250
12export const DONE_COLOR = 0x3fb950
13export const WIRE_COLOR = 0x56d4dd
14export const WIRE_SHOW_MS = 120_000
15
16export const INK = '#14161c'
17export const hex = (n) => '#' + n.toString(16).padStart(6, '0')
18export const colorOf = (model) => hex(MODEL_COLOR[modelKey(model)])
19
20// One braille frame per tick; each row starts at its own offset so the spinners don't march in step.
21export function spin(tick, offset = 0) {
22 return SPIN[(tick + offset * 3) % SPIN.length]
23}
24
25// "claude-opus-5-5" → "opus 5.5", "claude-haiku-4-5-20251001" → "haiku 4.5", "sonnet" → "sonnet".
26export function prettyModel(model) {
27 const m = String(model ?? '').toLowerCase()
28 const k = modelKey(m)
29 if (k === 'other') return m.replace(/^claude-/, '').slice(0, 14) || 'model'
30 const v = new RegExp(k + '-(\\d+)(?:-(\\d{1,2}))?(?!\\d)').exec(m)
31 return v ? k + ' ' + v[1] + (v[2] ? '.' + v[2] : '') : k
32}
33
34// The model pill's text, one width for every model so the columns line up.
35export const CHIP_WIDTH = 8
36export function chip(model) {
37 const k = modelKey(model)
38 const name = k === 'other' ? String(model ?? '?').replace(/^claude-/, '').slice(0, CHIP_WIDTH - 2) : k
39 const pad = CHIP_WIDTH - name.length
40 return ' '.repeat(Math.floor(pad / 2)) + name + ' '.repeat(Math.ceil(pad / 2))
41}
42
43const base = (p) => String(p ?? '').split('/').filter(Boolean).pop() ?? ''
44const cut = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
45
46// What an agent is doing, from its latest tool call: "Edit Paywall.swift", "Bash npm test".
47export function toolLabel(tool, input = {}) {
48 const t = String(tool ?? '')
49 const i = input ?? {}
50 if (['Read', 'Edit', 'Write', 'NotebookEdit'].includes(t)) return t + ' ' + base(i.file_path ?? i.notebook_path)
51 if (t === 'Bash') return 'Bash ' + cut(String(i.command ?? '').trim().split(/\s+/).slice(0, 3).join(' '), 28)
52 if (t === 'Grep' || t === 'Glob') return t + ' ' + cut(String(i.pattern ?? ''), 24)
53 if (t === 'WebFetch') return 'Fetch ' + (/^https?:\/\/([^/]+)/.exec(String(i.url ?? ''))?.[1] ?? '')
54 if (t === 'WebSearch') return 'Search ' + cut(String(i.query ?? ''), 24)
55 if (t === 'Agent') return 'Agent ' + cut(String(i.description ?? i.subagent_type ?? ''), 24)
56 if (t === 'Skill') return 'Skill ' + String(i.skill ?? '')
57 if (t === 'TodoWrite') return 'Plan'
58 if (t.startsWith('mcp__')) return t.split('__').slice(2).join(' ')
59 return t
60}
61
62// Which columns fit in `cols` cells of band body.
63export function columns(cols) {
64 const c = Number(cols) || 80
65 return { kind: c >= 92, effort: c >= 78, tok: c >= 68, role: c >= 88 ? 16 : 12 }
66}
67
68// The run's cast in dispatch order. While anything still works, finished subagents of the run
69// stay as dim ✓ rows so the tree reads as the run's history; otherwise `stage` decides.
70export function cast(staged, agents, run) {
71 const rows = [...staged.rows]
72 if (staged.tally.working > 0) {
73 for (const a of agents.values()) if (a.run === run && a.state === 'done' && !rows.includes(a)) rows.push(a)
74 }
75 const at = (r) => (typeof r.start === 'number' ? r.start : Infinity)
76 return rows.sort((a, b) => at(a) - at(b))
77}
78
79const BUSY = new Set(['working'])
80
81// How the engine's agent list reads on a row. waiting and idle replace the spinner of a working
82// agent; failed and killed replace the done mark of a finished one.
83export const LIST_STATUS = {
84 waiting: { glyph: '⏸', word: 'waiting', color: 'warning' },
85 idle: { glyph: '·', word: 'idle', color: 'inactive' },
86 failed: { glyph: '✗', word: 'failed', color: 'error' },
87 killed: { glyph: '✗', word: 'killed', color: 'error' },
88}
89const BROKEN = new Set(['failed', 'killed'])
90// A workflow agent's reason: plain `workflow` when the script ran the table's model, `workflow, table says X · Y`
91// when it did not. Neither is an escalation; the drift reads `≠`, not `↑`.
92const isWorkflow = (reason) => typeof reason === 'string' && reason.startsWith('workflow')
93const isDrift = (reason) => isWorkflow(reason) && reason !== 'workflow'
94const escalation = (reason) => !!reason && reason !== 'table' && !isWorkflow(reason)
95
96// One band row per agent. `wire` maps a pane name to what its worker published (tool, tok, model).
97// `view` is the id of the agent whose transcript is on screen.
98export function boardRows(rows, { now, tick = 0, cols = 80, wire = new Map(), transport = 'pane', view = null } = {}) {
99 const c = columns(cols)
100 return rows.map((r, i) => {
101 const pub = r.kind === 'pane' ? wire.get(r.name) ?? null : null
102 const model = r.model ?? pub?.model ?? null
103 const busy = BUSY.has(r.state)
104 const done = r.kind === 'sub' ? r.state === 'done' : r.state === 'done' || r.state === 'idle'
105 const ls = r.kind === 'sub' ? LIST_STATUS[r.listStatus] : undefined
106 const broken = done && !!ls && BROKEN.has(r.listStatus)
107 const paused = !done && !!ls && !BROKEN.has(r.listStatus)
108 const tool = r.tool ?? pub?.tool ?? null
109 const tok = r.kind === 'sub' ? r.input + r.output : pub?.tok ?? null
110 const start = r.start ?? pub?.start ?? null
111 const kind = r.kind !== 'pane' ? 'sub' : r.vendor && r.vendor !== 'claude' && r.vendor !== 'agent' ? r.vendor : transport
112 let activity
113 if (r.state === 'blocked') activity = '⚑ waiting on you'
114 else if (broken || paused) activity = ls.word
115 else if (done) activity = r.kind === 'sub' ? 'done' : r.state
116 else if (r.state === 'open') activity = 'open'
117 else activity = tool ?? (escalation(r.reason) ? '↑ ' + r.reason : isDrift(r.reason) ? '≠ ' + r.reason : r.kind === 'pane' ? 'working' : 'thinking')
118 return {
119 id: r.id,
120 name: r.name ?? null,
121 pane: r.kind === 'pane',
122 tree: r.tree ?? (i === rows.length - 1 ? '└─' : '├─'),
123 inView: view != null && r.id === view,
124 glyph: broken ? ls.glyph : done ? DONE_GLYPH : KIND_GLYPH[r.kind] ?? '·',
125 glyphColor: broken || paused ? ls.color : done ? hex(DONE_COLOR) : colorOf(model),
126 role: cut(String(r.role ?? '?'), c.role).padEnd(c.role),
127 kind: c.kind ? String(kind).slice(0, 5).padEnd(5) : null,
128 chip: model ? chip(model) : chip(r.kind === 'sub' ? '?' : r.vendor ?? 'pane'),
129 chipColor: colorOf(model),
130 effort: c.effort ? (effortBar(r.effort) + ' ' + String(r.effort ?? '').slice(0, 4)).padEnd(6) : null,
131 spinner: paused ? ls.glyph : busy ? spin(tick, i) : done ? ' ' : '·',
132 activity,
133 escalated: escalation(r.reason),
134 reason: r.reason && r.reason !== 'table' && r.reason !== 'workflow' ? r.reason : null,
135 time: start != null && !r.adopted ? elapsed((r.end ?? now) - start).padStart(5) : ' ',
136 tok: c.tok ? (tok != null && !r.adopted ? tokens(tok) : '').padStart(6) : null,
137 dim: done && !broken,
138 busy,
139 }
140 })
141}
142
143// "◆ agentille · run r1 · gauntlet" on the left, "◇2 ▣1 · 50.3k" on the right.
144export function header({ run, formation = null, squads = [], rows = [], tally }) {
145 const left = ['run ' + run]
146 if (formation) left.push(formation)
147 if (squads.length) left.push(squads.join('+'))
148 const subs = rows.filter((r) => r.kind === 'sub' && r.state === 'working').length
149 const panes = rows.filter((r) => r.kind === 'pane' && r.state !== 'done' && r.state !== 'idle').length
150 const right = [KIND_GLYPH.sub + subs + ' ' + KIND_GLYPH.pane + panes]
151 if (tally.tok) right.push(tokens(tally.tok))
152 return { left: left.join(' · '), right: right.join(' · ') }
153}
154
155// The tree's root: the lead session itself.
156export function leadLine({ model, busy, waiting }) {
157 const what = busy ? 'orchestrating' : waiting > 0 ? 'waiting on ' + waiting : 'idle'
158 return { text: 'lead · ' + prettyModel(model ?? 'opus'), state: what, color: colorOf(model ?? 'opus') }
159}
160
161// The newest wire message worth a row: ⇄ exec-1 → lead "slice built" 12s.
162export function wireRow(log, now) {
163 const m = log[log.length - 1]
164 if (!m || now - m.at > WIRE_SHOW_MS) return null
165 const ago = Math.max(0, Math.round((now - m.at) / 1000))
166 return { arrow: m.from + ' → ' + m.to, text: '"' + cut(m.summary, 60) + '"', ago: ago < 60 ? ago + 's' : Math.floor(ago / 60) + 'm' }
167}
168
169// ── the deck: the optional /agt-deck pane ─────────────────────────────────────
170
171export const CAST_WIDTH = 14
172
173// Mascots side by side, as many as fit `cols`: each a column of five lines (three of mascot,
174// role, model). `frameOf(mood, tick, role)` is mascot.js frame; `moodOf(row)` its mood.
175export function castColumns(rows, { cols, tick = 0, frameOf, moodOf }) {
176 const fit = Math.max(1, Math.floor((Number(cols) || 80) / CAST_WIDTH))
177 const shown = rows.slice(0, fit)
178 return {
179 cells: shown.map((r) => {
180 const mood = moodOf(r)
181 const [head, body, legs] = frameOf(mood, tick, r.kind === 'sub' ? r.role : 'executor')
182 return { lines: [head, body, legs].map((l) => l.padEnd(CAST_WIDTH)), role: cut(String(r.role ?? '?'), CAST_WIDTH - 4), model: r.model ?? null, glyph: r.state === 'done' ? DONE_GLYPH : KIND_GLYPH[r.kind] ?? '·', dim: r.state === 'done' || r.state === 'idle' }
183 }),
184 more: rows.length - shown.length,
185 }
186}
187
188const clock = (iso) => {
189 const d = new Date(iso)
190 return Number.isNaN(d.getTime()) ? '--:--:--' : d.toTimeString().slice(0, 8)
191}
192
193// The routing timeline, newest last: "12:10:04 planner → opus · high table".
194export function routingLines(decisions, n = 8) {
195 return decisions.slice(-n).map((d) => ({
196 at: clock(d.at),
197 who: cut(String(d.role), 16).padEnd(16) + (d.kind === 'pane' ? ' ▣' : ' ◇'),
198 route: (prettyModel(d.model) + ' · ' + (d.effort ?? '')).padEnd(21),
199 color: colorOf(d.model),
200 reason: escalation(d.reason) ? '↑ ' + d.reason : isDrift(d.reason) ? '≠ ' + d.reason : d.reason === 'workflow' ? 'workflow' : 'table',
201 escalated: escalation(d.reason),
202 drift: isDrift(d.reason),
203 }))
204}
205
206// The wire log, newest last: "12:12:01 exec-1 → lead done slice 1 built".
207export function wireLines(log, n = 6) {
208 return log.slice(-n).map((m) => ({ at: clock(new Date(m.at).toISOString()), arrow: (m.from + ' → ' + m.to).padEnd(18), kind: m.kind.padEnd(5), text: cut(m.summary, 70) }))
209}
210
211// Token bars per role from a ledger (live.js), widest first.
212export function tokenBars(l, width = 20) {
213 const roles = Object.entries(l?.roles ?? {}).map(([name, t]) => ({ name, tok: t.input + t.output }))
214 const top = Math.max(1, ...roles.map((r) => r.tok))
215 return roles.sort((a, b) => b.tok - a.tok).slice(0, 8).map((r) => {
216 const fill = Math.max(r.tok > 0 ? 1 : 0, Math.round((r.tok / top) * width))
217 return { name: cut(r.name, 16).padEnd(16), bar: '█'.repeat(fill) + '░'.repeat(width - fill), tok: tokens(r.tok).padStart(6) }
218 })
219}
220
221// ── the swarm line: the whole run in one line, by phase ──────────────────────
222
223export const PHASES = ['plan', 'build', 'review', 'other']
224const LANE_CAP = 8 // cells per lane before it reads "+n"
225
226export function phaseOf(role) {
227 const r = String(role ?? '')
228 if (r === 'planner' || r === 'plan-reviewer' || r === 'ui-prototyper') return 'plan'
229 if (r === 'executor' || r === 'adversary') return 'build'
230 if (r.endsWith('-reviewer')) return 'review'
231 return 'other'
232}
233
234// One cell per agent of the run, done ones included, so a run of twelve reads in one line where
235// the tree only has room for a few rows. A working cell pulses (◆/◇, ▣/□) on the ticker, offset
236// per cell; a finished one is a dim ✓, a failed or killed one ✗. Pane workers come from their
237// routes (one per spawn), working until the route ends. Undefined below three agents (the tree
238// already shows that many) and outside an /agt run (`adhoc` is every plain subagent of the session).
239// A lane past LANE_CAP keeps every working and failed cell and fills the rest with the newest
240// finished ones, in start order: the live agent is never the one cut.
241export function swarm({ agents, routes = [], run, tick = 0 }) {
242 if (run === 'adhoc') return undefined
243 const cells = []
244 for (const a of agents.values()) {
245 if (a.run !== run) continue
246 const broken = BROKEN.has(a.listStatus)
247 const working = a.state === 'working' && !broken
248 cells.push({ at: a.start ?? 0, phase: phaseOf(a.role), kind: 'sub', working, broken, model: a.model })
249 }
250 for (const r of routes) {
251 if (r.run !== run) continue
252 cells.push({ at: r.start ?? 0, phase: phaseOf(r.agent ?? r.role), kind: 'pane', working: r.end == null, broken: false, model: r.model })
253 }
254 if (cells.length < 3) return undefined
255 cells.sort((a, b) => a.at - b.at)
256 const lanes = []
257 for (const phase of PHASES) {
258 const mine = cells.filter((c) => c.phase === phase)
259 if (!mine.length) continue
260 const keep = new Set(mine.filter((c) => c.working || c.broken).slice(-LANE_CAP))
261 for (let i = mine.length - 1; i >= 0 && keep.size < LANE_CAP; i--) keep.add(mine[i])
262 const shown = mine.filter((c) => keep.has(c)).map((c, i) => {
263 if (c.broken) return { glyph: '✗', color: 'error', dim: false }
264 if (!c.working) return { glyph: DONE_GLYPH, color: hex(DONE_COLOR), dim: true }
265 const on = (tick + i) % 4 < 2
266 return { glyph: c.kind === 'pane' ? (on ? '▣' : '□') : on ? '◆' : '◇', color: colorOf(c.model), dim: false }
267 })
268 lanes.push({ phase, cells: shown, more: mine.length - shown.length })
269 }
270 return { lanes, done: cells.filter((c) => !c.working).length, total: cells.length }
271}
272hooks/wire.js 130 lines1// The wire: how agentille sessions talk to each other. A worker pane publishes its live status
2// to the shared $.store (the lead's band reads it) and messages the lead when it finishes, so
3// the lead wakes on its own instead of polling the multiplexer. A blocked worker needs the
4// person, not the lead: the multiplexer reports it and the band flags it. Pure on purpose:
5// register.js makes every $ call.
6
7import { elapsed, tokens } from './live.js'
8
9export const WIRE_TAG = '[agt wire]'
10export const STATUS_PREFIX = 'wire:'
11export const STATUS_TTL_MS = 6 * 3_600_000
12export const PUBLISH_MS = 1000
13export const ANSWER_HEAD = 1500
14export const LOG_MAX = 40
15const SESSION_RE = /^[A-Za-z0-9_-]{1,128}$/
16const NAME_RE = /^agt-[A-Za-z0-9_-]{1,60}$/
17
18export const statusKey = (name) => STATUS_PREFIX + name
19
20// What a worker publishes about itself, at most once per PUBLISH_MS while it works.
21export function workerStatus({ name, session = null, state, tool = null, tok = 0, model = null, effort = null, start = null, now }) {
22 return { name, session, state, tool, tok, model, effort, start, at: now }
23}
24
25// A published status is trusted only while it is recent and names the pane it was read for.
26export function freshStatus(rec, name, now) {
27 return !!rec && typeof rec === 'object' && rec.name === name && typeof rec.at === 'number' && now - rec.at < STATUS_TTL_MS
28}
29
30// The env a lead hands each worker it opens: its own name and the lead's session, so the
31// worker knows where to send. A session id that is not a plain token is left out.
32export function wireEnv({ name, lead }) {
33 const out = []
34 if (NAME_RE.test(String(name ?? ''))) out.push('AGENTILLE_NAME=' + name)
35 if (SESSION_RE.test(String(lead ?? ''))) out.push('AGENTILLE_LEAD=' + lead)
36 return out
37}
38
39export function validLead(id) {
40 return SESSION_RE.test(String(id ?? '')) ? String(id) : null
41}
42
43// The message a finished worker sends its lead: one header line, the head of its answer, and
44// where the full answer was saved. The lead reads it as a peer message and wakes for it.
45export function doneMessage({ name, ms = 0, model = null, effort = null, tok = 0, answer = '', reportPath = null }) {
46 const body = String(answer ?? '').trim()
47 const head = body.length > ANSWER_HEAD ? body.slice(0, ANSWER_HEAD) + '…' : body
48 const meta = [elapsed(ms), [model, effort].filter(Boolean).join(' '), tok ? tokens(tok) + ' tok' : ''].filter(Boolean).join(' · ')
49 return [WIRE_TAG + ' ' + name + ' done · ' + meta, head, reportPath ? 'Full answer: ' + reportPath : ''].filter(Boolean).join('\n')
50}
51
52// The pane key in a `Full answer:` path (pane-<role>.<key>.md): which pane instance reported.
53const ANSWER_KEY = /\/pane-[^/.]+\.([A-Za-z0-9][A-Za-z0-9_-]{0,63})\.md$/
54
55// A received wire message → { from, kind, summary, key }, or null for anything else. `key` is null for a
56// legacy pane-<role>.md path or when there is no `Full answer:` line.
57export function parseWire(text) {
58 const s = String(text ?? '')
59 const at = s.indexOf(WIRE_TAG)
60 if (at < 0) return null
61 const lines = s.slice(at + WIRE_TAG.length).trim().split('\n')
62 const m = /^(\S+)\s+(done|note)\b\s*·?\s*(.*)$/.exec(lines[0] ?? '')
63 if (!m) return null
64 const [, from, kind, rest] = m
65 const first = lines.slice(1).map((l) => l.trim()).find((l) => l && !l.startsWith('Full answer:'))
66 const full = lines.slice(1).map((l) => l.trim()).filter((l) => l.startsWith('Full answer:')).pop() // the last: the answer head cannot shadow it
67 const key = ANSWER_KEY.exec(full ? full.slice('Full answer:'.length).trim() : '')?.[1] ?? null
68 const summary = kind === 'done' ? first ?? rest : rest || first || kind
69 return { from, kind, summary: summary.replace(/^[#>*\s-]+/, '').slice(0, 160), key }
70}
71
72// Short names for the band: agt-r1-exec-1 → exec-1.
73export function shortName(name) {
74 const parts = String(name ?? '').split('-')
75 return parts[0] === 'agt' && parts.length > 2 ? parts.slice(2).join('-') : String(name ?? '')
76}
77
78// A wire log entry, newest last, capped.
79export function logWire(log, entry) {
80 log.push(entry)
81 if (log.length > LOG_MAX) log.splice(0, log.length - LOG_MAX)
82 return log
83}
84
85// /agt-tell <worker> <message> → { name, text } | { usage } | { error }. The worker may be named
86// in full or by its role (exec-1); `panes` are the agt- panes the lead sees now.
87export const TELL_USAGE = 'Usage: /agt-tell <worker> <message> (worker: exec-1 or agt-<run>-exec-1)'
88export function parseTellArgs(args, panes = []) {
89 const m = /^\s*(\S+)\s+([\s\S]+?)\s*$/.exec(String(args ?? ''))
90 if (!m) return { usage: TELL_USAGE }
91 const [, who, raw] = m
92 const text = raw.replace(/^(["'])([\s\S]*)\1$/, '$2').trim()
93 const hits = panes.filter((p) => p.name === who || shortName(p.name) === who)
94 if (hits.length === 0) return { error: 'No worker named ' + who + '. ' + TELL_USAGE }
95 if (hits.length > 1) return { error: who + ' matches ' + hits.length + ' workers; use the full agt- name.' }
96 return text ? { name: hits[0].name, text } : { usage: TELL_USAGE }
97}
98
99// ── lead self-wake ────────────────────────────────────────────────────────────
100
101export const WAKE_TAG = '[agt wake]'
102export const WAKE_MS = 20_000
103
104// Panes of the lead that finished ≥ WAKE_MS ago without a wire done message, and that the lead has
105// not been woken for yet. A blocked pane is not due: it waits on the person, and the one wake a pane
106// gets is kept for when it finishes. `seen` is the reaper's pane id → { since }; `wireDone` and `woken`
107// hold pane ids, so a respawned role's new pane is judged on its own.
108export function wakeDue(pool, seen, wireDone, woken, now) {
109 return pool.filter((p) => {
110 if (p.state !== 'done') return false
111 const since = seen.get(p.id)?.since
112 return typeof since === 'number' && now - since >= WAKE_MS && !wireDone.has(p.id) && !woken.has(p.id)
113 })
114}
115
116// How to read a pane from its transport: herdr by agent name, unwrapped, as panes-mode.md's harvest
117// loop does; tmux by pane `id`.
118export function readCommand(transport, id, name) {
119 if (transport === 'herdr') return NAME_RE.test(String(name ?? '')) ? 'herdr agent read ' + name + ' --source recent-unwrapped --lines 200' : null
120 if (transport === 'tmux' && /^[A-Za-z0-9:%._-]{1,40}$/.test(String(id ?? ''))) return 'tmux capture-pane -p -t ' + id + ' -S -200'
121 return null
122}
123
124// The message the lead sends its own session when a worker finished without reporting.
125export function wakeMessage({ name, transport, id, answerPath = null }) {
126 const cmd = readCommand(transport, id, name)
127 const read = answerPath ? 'Its answer is saved at ' + answerPath + '; read that.' : cmd ? 'Read it with: ' + cmd : 'Read the pane.'
128 return WAKE_TAG + ' ' + name + ' finished and has not reported. ' + read + ' Then harvest it and close_pane.'
129}
130hooks/squads.js 49 lines1// Squad detection: score .claude-plugin/squads.json against the repo's deps + paths.
2// Pure; register.js reads the files and injects the result into the /agt skill text.
3
4export function depsOf(pkgText) {
5 try {
6 const p = JSON.parse(pkgText)
7 return new Set([...Object.keys(p.dependencies ?? {}), ...Object.keys(p.devDependencies ?? {})])
8 } catch {
9 return new Set()
10 }
11}
12
13// existing: Set of squad signal paths that exist in the repo
14export function activeSquads(config, deps, existing) {
15 const out = []
16 for (const s of config?.squads ?? []) {
17 const matched = [
18 ...(s.signals?.deps ?? []).filter((d) => deps.has(d)),
19 ...(s.signals?.paths ?? []).filter((p) => existing.has(p)),
20 ]
21 if (matched.length >= (s.minScore ?? 2)) out.push({ name: s.name, label: s.label, matched, adds: s.adds ?? [], checklists: s.checklists ?? {} })
22 }
23 return out
24}
25
26export function allPaths(config) {
27 return [...new Set((config?.squads ?? []).flatMap((s) => s.signals?.paths ?? []))]
28}
29
30export function injection(active) {
31 if (active.length === 0) return ''
32 const adds = [...new Set(active.flatMap((s) => s.adds))]
33 const roles = {}
34 for (const s of active) {
35 for (const [role, lines] of Object.entries(s.checklists)) {
36 roles[role] ??= new Set()
37 for (const l of lines) roles[role].add(l)
38 }
39 }
40 const out = ['', '## Squads detected by the agentille mod', '']
41 for (const s of active) out.push('- **' + s.name + '** (' + s.label + ') — signals: ' + s.matched.join(', '))
42 out.push('', 'Add specialists (see `squads.md`): ' + adds.map((r) => '`agentille:agentille-' + r + '`').join(', '), '')
43 for (const [role, lines] of Object.entries(roles)) {
44 out.push('Squad checklist for **' + role + '**:')
45 for (const l of lines) out.push('- ' + l)
46 }
47 return out.join('\n') + '\n'
48}
49hooks/mascot.js 79 lines1// The worker pane's mascot: three rows of block glyphs and a caption, pure strings.
2// register.js owns the clock and the drawing; this file only says what to show.
3
4export const MASCOT_COLOR = 0xd97757
5export const HELLO_MS = 2400
6export const BYE_MS = 2400
7
8export const MODEL_COLOR = {
9 haiku: 0x9aa0a6,
10 sonnet: 0x4f8ef7,
11 opus: 0xf2a33a,
12 fable: 0xa77bff,
13 other: 0xc8c8c8,
14}
15
16export function modelKey(model) {
17 const m = String(model ?? '').toLowerCase()
18 for (const k of ['fable', 'opus', 'sonnet', 'haiku']) if (m.includes(k)) return k
19 return 'other'
20}
21
22const HEAD = ' ▐▛███▜▌'
23const BODY = ' ▝▜█████▛▘'
24const LEGS = [' ▘▘ ▝▝', ' ▝▝ ▘▘']
25const ARM = { hi: ' ╱', bye: ' ╲' }
26
27// One small accessory per role, so the band shows who is who: a swapped head or body row,
28// same width class, single-cell glyphs only. A role without an entry wears nothing.
29export const ACCESSORY = {
30 executor: { head: ' ▟▛███▜▙' }, // hard hat
31 planner: { head: ' ▐▛█▲█▜▌' }, // thinking cap
32 'plan-reviewer': { head: ' ▐▛███◉▌' }, // monocle
33 'code-reviewer': { head: ' ▐▛○─○▜▌' }, // glasses
34 'security-reviewer': { body: ' ▝▜█▚▄▞█▛▘' }, // moustache
35 'design-reviewer': { body: ' ▝▜█▶▪◀█▛▘' }, // bow tie
36 adversary: { head: ' ◥▐▛███▜▌◤' }, // horns
37 'payments-reviewer': { body: ' ▝▜██$██▛▘' }, // dollar sign
38 'perf-reviewer': { head: ' ▌▛███▜▐' }, // headphones
39}
40
41// mood: 'hi' | 'working' | 'bye'. Three rows; the legs step on every other tick while working.
42export function frame(mood, tick = 0, role = '') {
43 const wear = ACCESSORY[role] ?? {}
44 return [(wear.head ?? HEAD) + (ARM[mood] ?? ''), wear.body ?? BODY, LEGS[mood === 'working' ? tick & 1 : 0]]
45}
46
47const mmss = (ms) => {
48 const s = Math.max(0, Math.round(ms / 1000))
49 return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
50}
51
52export function caption({ mood, agent, model, effort, ms }) {
53 const who = [agent, model, effort].filter(Boolean).join(' · ')
54 if (mood === 'hi') return 'hi! ' + who
55 if (mood === 'working') return agent + ' · working ' + mmss(ms ?? 0)
56 if (mood === 'bye') return 'bye! ✓ done ' + mmss(ms ?? 0)
57 return who + (ms != null ? ' · done ' + mmss(ms) : '')
58}
59
60// hi outranks the rest until it is over; 'idle' means nothing is animating.
61export function moodAt({ hiUntil = 0, byeUntil = 0, working = false, now }) {
62 if (now < hiUntil) return 'hi'
63 if (now < byeUntil) return 'bye'
64 return working ? 'working' : 'idle'
65}
66
67// The deck's cast: a subagent says hi for its first HELLO_MS, steps while it works, and waves
68// bye once it finishes.
69export function agentMood(a, now) {
70 if (a.state !== 'working') return 'bye'
71 return now - a.start < HELLO_MS ? 'hi' : 'working'
72}
73
74// AGENTILLE_WORKER=<agent>:<model>:<effort>, set when the lead opens the pane; effort may be empty.
75export function parseWorker(text) {
76 const [agent, model, effort = ''] = String(text ?? '').split(':')
77 return /^[a-z][a-z-]*$/.test(agent) && /^[\w.-]+$/.test(model ?? '') ? { agent, model, effort } : null
78}
79hooks/highlight.js 233 lines1// agentille highlight — make a long /agt reply scannable. Pure on purpose: register.js
2// draws; these helpers decide what to light and what goes on the essentials card.
3//
4// essentials — the next action, the one flag, the one done line, read off the text. No model.
5// Skipped for a final card (an answer opening with ✓ or ✗): it would repeat it.
6// lightTokens — wrap paths, versions, refs and numbers in backticks so the dim body
7// shows them lit. Code, links and fences are never touched.
8
9import { words } from './focus.js'
10
11export const CARD_MIN_WORDS = 40
12export const LIT_MAX = 10_000
13export const LIT_MEMO_MAX = 500
14export const MARK = { next: '→', flag: '⚑', done: '✓' }
15export const SPAN_COLOR = { path: '#7fd3e6', cmd: '#4f8ef7', version: '#a77bff', number: '#f2a33a', ref: '#3fb950' }
16export const HIGHLIGHT_USAGE = 'Usage: /agt-highlight [on|all|off]'
17
18const MAX_LINE = 96
19
20const PATH_ABS = /^(~\/|\.{1,2}\/|\/)[\w@.\/-]*[\w@-](:\d+(-\d+)?)?$/
21const PATH_REL = /^[\w@.-]+(\/[\w@.-]+)+\.[A-Za-z0-9]{1,8}(:\d+(-\d+)?)?$/
22const VERSION = /^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$|^v\d+(\.\d+)?$/
23const REF = /^#\d+$/
24const NUMBER = /^\d+([.,]\d+)?(×|x|%|k|K|M|ms|s|min)$/
25
26function kindOf(core) {
27 if (core.includes('://')) return null
28 if (PATH_ABS.test(core) || PATH_REL.test(core)) return 'path'
29 if (VERSION.test(core)) return 'version'
30 if (REF.test(core)) return 'ref'
31 if (NUMBER.test(core)) return 'number'
32 return null
33}
34
35// Punctuation that hugs a token is not part of it: "(#75)," lights #75 only.
36function peel(token) {
37 const lead = /^(?:[(\[{"'*_]|~(?!\/))+/.exec(token)?.[0] ?? ''
38 const rest = token.slice(lead.length)
39 const trail = /[.,;:!?)\]}"'*_…]+$/.exec(rest)?.[0] ?? ''
40 return { lead, core: rest.slice(0, rest.length - trail.length), trail }
41}
42
43// A fence opens at any indent (list items nest them) and closes only on the same
44// character, at least as long, alone on its line. An unclosed fence runs to the end.
45function fenceMask(lines) {
46 let open = null
47 return lines.map((l) => {
48 // Quoted code blocks fence the same way: `> ```` opens one too.
49 l = l.replace(/^\s*(?:>\s?)+/, '')
50 if (open) {
51 const c = /^\s*(`+|~+)\s*$/.exec(l)
52 if (c && c[1][0] === open[0] && c[1].length >= open.length) open = null
53 return true
54 }
55 // A backtick fence's info string has no backtick, so ```npm test``` is inline code.
56 const m = /^\s*(`{3,})[^`]*$/.exec(l) ?? /^\s*(~{3,})/.exec(l)
57 if (m) {
58 open = m[1]
59 return true
60 }
61 return false
62 })
63}
64
65const PROTECTED = /(`[^`]*`|!?\[[^\]]*\]\([^)]*\)|<[^>\s]+>)/
66
67function lightPlain(seg) {
68 return seg
69 .split(/(\s+)/)
70 .map((tok) => {
71 if (!tok.trim() || tok.includes('://') || tok.startsWith('www.')) return tok
72 const { lead, core, trail } = peel(tok)
73 return core && kindOf(core) ? lead + '`' + core + '`' + trail : tok
74 })
75 .join('')
76}
77
78export function lightTokens(md) {
79 const lines = String(md ?? '').split('\n')
80 const fenced = fenceMask(lines)
81 return lines
82 .map((l, i) => {
83 if (fenced[i]) return l
84 if (/^\s{0,3}\[[^\]]+\]:/.test(l)) return l
85 if ((l.match(/`/g) ?? []).length % 2) return l
86 return l.split(PROTECTED).map((seg, j) => (j % 2 ? seg : lightPlain(seg))).join('')
87 })
88 .join('\n')
89}
90
91function clip(s) {
92 const t = s.replace(/\s+/g, ' ').trim()
93 return t.length > MAX_LINE ? t.slice(0, MAX_LINE - 1) + '…' : t
94}
95
96const NEGATED = /\b(0|no|zero|without)\s+(\w+\s+)?(failures?|failed|failing|fails?|errors?|warnings?|blockers?)\b/gi
97
98function isFlag(line) {
99 const t = line.replace(NEGATED, '')
100 return (
101 /[⚑✗]/.test(t) ||
102 /\b(blockers?|blocked|failed|failing)\b/i.test(t) ||
103 /\b(FAIL|CONCERNS|REVISE)\b/.test(t) ||
104 /needs you/i.test(t) ||
105 /\b[1-9]\d*\s+(\w+\s+)?(errors?|warnings?)\b/i.test(t) ||
106 /^(error|warning):/i.test(t)
107 )
108}
109
110function isDone(line) {
111 if (/\bnot\s+(yet\s+)?(done|merged|released|passing)\b/i.test(line)) return false
112 return /[✓✅]/.test(line) || /\b(done|passes|passed|passing|merged|released|shipped|tagged|green)\b/i.test(line)
113}
114
115function nextText(line) {
116 if (line.startsWith('→')) return line.slice(1).trim()
117 const m = /^next(?: steps?)?\s*[:—-]\s*(.*)$/i.exec(line)
118 if (m) return m[1].trim()
119 return /next step/i.test(line) ? line : ''
120}
121
122// → [{ kind, text }]: at most one per kind, first line wins, next · flag · done.
123export function essentials(text) {
124 const lines = String(text ?? '').split('\n')
125 const fenced = fenceMask(lines)
126 const found = {}
127 lines.forEach((raw, i) => {
128 if (fenced[i]) return
129 if (/^\s*#{1,6}(\s|$)/.test(raw)) return
130 if (/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(raw)) return
131 const line = raw
132 .replace(/^\s*(?:(?:[-*+]|\d+[.)])\s+|>\s*)+/, '')
133 .replace(/\*\*|__/g, '')
134 .trim()
135 if (!line) return
136 const next = !found.next && nextText(line)
137 if (next) {
138 found.next = clip(next)
139 return
140 }
141 if (!found.flag && isFlag(line)) {
142 const t = clip(line.replace(/^⚑️?\s*/, ''))
143 if (t) found.flag = t
144 return
145 }
146 if (isFlag(line)) return // a second flag is still a flag, never the done line
147 if (!found.done && isDone(line)) {
148 const t = clip(line.replace(/^[✓✅]️?\s*/, ''))
149 if (t) found.done = t
150 }
151 })
152 return ['next', 'flag', 'done'].filter((k) => found[k]).map((kind) => ({ kind, text: found[kind] }))
153}
154
155// A line → coloured pieces. Backtick delimiters are dropped; the texts rejoin to the line.
156export function spans(line) {
157 const out = []
158 const push = (text, kind) => {
159 if (!text) return
160 const last = out[out.length - 1]
161 if (kind === 'plain' && last?.kind === 'plain') last.text += text
162 else out.push({ text, kind })
163 }
164 String(line ?? '').split(/`([^`]*)`/).forEach((seg, j) => {
165 if (j % 2) return push(seg, kindOf(seg) ?? 'cmd')
166 for (const tok of seg.split(/(\s+)/)) {
167 if (!tok.trim()) {
168 push(tok, 'plain')
169 continue
170 }
171 const { lead, core, trail } = peel(tok)
172 const kind = !core ? 'plain' : /^\/[a-z][\w:-]*$/.test(core) ? 'cmd' : (kindOf(core) ?? 'plain')
173 push(lead, 'plain')
174 push(core, kind)
175 push(trail, 'plain')
176 }
177 })
178 return out
179}
180
181// The run's final card opens with ✓ (done) or ✗ (failed): it is its own summary, so no
182// essentials card and no word floor, only the tokens lit.
183const isFinalCard = (text) => /^[✓✗]/.test(String(text ?? '').trimStart())
184
185export function highlight(text) {
186 const final = isFinalCard(text)
187 if (!final && words(text) < CARD_MIN_WORDS) return null
188 const card = final ? [] : essentials(text)
189 const lit = lightTokens(text)
190 const body = lit.length <= LIT_MAX ? lit : null
191 if (!card.length && body === null) return null
192 return { card, body }
193}
194
195// highlight() is regex work and the transcript redraws on every ticker tick, so the result
196// is kept per message id while its text is unchanged (a streaming reply recomputes).
197export function highlightFor(memo, id, text) {
198 if (!id) return highlight(text)
199 const hit = memo.get(id)
200 if (hit && hit.text === text) return hit.h
201 const h = highlight(text)
202 memo.delete(id)
203 memo.set(id, { text, h })
204 if (memo.size > LIT_MEMO_MAX) memo.delete(memo.keys().next().value)
205 return h
206}
207
208// The lit/dim decision is made once per message id, on its first sight, so a reply
209// does not flip style when a later turn changes.
210export function litFor(memo, id, { on, agtTurn }) {
211 if (!id) return !!(on && agtTurn)
212 if (!memo.has(id)) {
213 memo.set(id, !!agtTurn)
214 if (memo.size > LIT_MEMO_MAX) memo.delete(memo.keys().next().value)
215 }
216 return !!on && memo.get(id)
217}
218
219export function parseHighlightArgs(args) {
220 const a = String(args ?? '').trim().toLowerCase()
221 if (!a) return { show: true }
222 if (a === 'on') return { on: true, all: false }
223 if (a === 'all') return { on: true, all: true }
224 if (a === 'off') return { on: false, all: false }
225 return { error: HIGHLIGHT_USAGE }
226}
227
228export function highlightText(on, all = false) {
229 if (!on) return 'Highlight: off — replies draw as usual. /agt-highlight on turns it back on.'
230 const which = all ? 'all — every long reply gets' : 'on — /agt replies get'
231 return 'Highlight: ' + which + ' an essentials card and a dim body with paths, versions, refs and numbers lit.' + (all ? '' : ' /agt-highlight all covers plain prompts too.')
232}
233hooks/focus.js 53 lines1// agentille focus — what in a run needs the reader. Pure on purpose: register.js draws.
2//
3// Flags are read straight off agent results (a REVISE, a FAIL verdict, a failed check, a
4// blocked pane). No model call.
5
6export function words(text) {
7 const t = String(text ?? '').trim()
8 return t ? t.split(/\s+/).length : 0
9}
10
11// One agent's final answer → a flag line, or null. Reviewers share `VERDICT: PASS|CONCERNS|FAIL`;
12// the executor's VERIFICATION block carries real exit codes and counts.
13export function flagOf(role, answer) {
14 const a = String(answer ?? '')
15 // The first verdict word decides, as routing.js verdictOf reads it: "APPROVE — nothing warrants a REVISE" is an approval.
16 if (role === 'plan-reviewer' && /\b(APPROVE|REVISE)\b/.exec(a)?.[1] === 'REVISE') return role + ' asked for a revised plan'
17 const v = /VERDICT:\s*(FAIL|CONCERNS)\b/.exec(a)
18 if (v && role.endsWith('reviewer')) return role + ': ' + v[1] + (v[1] === 'FAIL' ? ' — blocks ship' : ' — fix before ship')
19 if (role === 'adversary') {
20 const b = /\bBROKEN:\s*([1-9]\d*)/.exec(a)
21 if (b) return 'adversary broke ' + b[1] + ' case' + (b[1] === '1' ? '' : 's')
22 }
23 if (role === 'executor') {
24 const ver = (/VERIFICATION:([\s\S]*?)(\n[A-Z]+:|$)/.exec(a) ?? [])[1] ?? ''
25 if (/exit(?:\s*code)?\s*[:=]?\s*[1-9]\d*/i.test(ver) || /\b[1-9]\d* (failed|failing|fail|errors?)\b/i.test(ver)) return 'executor: verification failed'
26 if (/did not run|not run|couldn'?t run|could not run/i.test(ver)) return 'executor: verification not run'
27 if (/^CONTEXT\s/m.test(a)) return 'executor handed off at its context limit'
28 }
29 return null
30}
31
32// Panes that wait on a person: herdr reports `blocked` when an approval is on screen. Panes whose
33// answer nobody has taken (`stranded`, a set of names) are kept open, so they say so.
34export function paneFlags(panes, stranded = new Set()) {
35 const list = panes ?? []
36 return [
37 ...list.filter((p) => p.state === 'blocked').map((p) => p.name + ' is waiting on you'),
38 ...list.filter((p) => stranded.has(p.name)).map((p) => p.name + (p.state === 'idle' ? ' idle' : ' done') + ', not harvested'),
39 ]
40}
41
42// /agt-focus arguments → { on } | { show: true } | { error }
43export function parseFocusArgs(args) {
44 const a = String(args ?? '').trim().toLowerCase()
45 if (!a) return { show: true }
46 return a === 'on' || a === 'off' ? { on: a === 'on' } : { error: 'Usage: /agt-focus [on|off]' }
47}
48
49export function focusText(on, flags) {
50 const head = 'focus: ' + (on ? 'on (agent flags show above the prompt)' : 'off (no flags above the prompt)')
51 return flags.length ? head + '\n' + flags.map((f) => '⚑ ' + f).join('\n') : head + '\nNothing to flag right now.'
52}
53hooks/panes.js 326 lines1// Pane transport: which multiplexer this session runs in (Herdr, tmux, or none), how to
2// open a worker pane in it, and how to read tmux panes back as live.js pane agents.
3// Pure on purpose — the hooks loader never follows `$` across an import — so every
4// $.process / $.env / $.fs call lives in register.js and only feeds these helpers.
5
6import { ROLES, parseHeader } from './routing.js'
7
8export const SAFE_RUN = /^[A-Za-z0-9_-]{1,64}$/
9export const NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/
10const ROLE_RE = /^[a-z0-9-]+$/
11const PANE_KEY = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
12const MODEL_LIKE = /^[A-Za-z0-9][\w.-]*$/
13const BARE_WORD = /^[a-z-]+$/
14const MODEL_RE = /^(haiku|sonnet|opus|fable|claude-[a-z0-9.-]+)$/
15export const PROBE = { timeoutMs: 5000 }
16export const HERDR_START_TIMEOUT = { timeoutMs: 60000 }
17const TAB = '\t'
18
19export const SPAWN_ROLE = 'spawn'
20export const DEFAULT_MODEL = 'sonnet'
21export const SPAWN_USAGE = 'Usage: /agt-spawn "task" [--model sonnet|opus|haiku|fable]'
22
23// herdr when HERDR_ENV=1 and `herdr --version` ran; else tmux when $TMUX is set and
24// `tmux -V` ran; else none. Callers pass what their probes found (a probe that rejects
25// or exits non-zero is false).
26export function pickTransport({ herdrOk, tmuxOk }) {
27 return herdrOk ? 'herdr' : tmuxOk ? 'tmux' : 'none'
28}
29
30export const PANE_RULE = 'Pane rule: panes only when ≥2 slices build at once or `--mode panes`: each slice\'s executor (and the adversary) via spawn_pane (agent + header); everyone else is a subagent. close_pane after harvest.'
31
32// The block appended to the /agt skill prompt: which transport is live and, when the mod
33// registered the pane tools, the rule for using them.
34export function transportBlock(transport, tools = false) {
35 const note = transport === 'none' ? 'No pane transport here: parallel slices run as a workflow, else subagent waves.\n' : tools ? PANE_RULE + '\n' : ''
36 return '\n## Pane transport (agentille mod)\n\ntransport: ' + transport + '\n' + note
37}
38
39// ── names ─────────────────────────────────────────────────────────────────────
40
41export function paneName(run, role) {
42 const name = 'agt-' + run + '-' + role
43 return SAFE_RUN.test(run) && ROLE_RE.test(role) && NAME_RE.test(name) ? name : null
44}
45
46export function newRunId() {
47 let id = ''
48 while (id.length < 6) id += Math.floor(Math.random() * 36).toString(36)
49 return id
50}
51
52// `agt-<run>-<role…>` → { run, role } with both safe to put in a path, else null.
53export function splitName(name) {
54 const parts = String(name ?? '').split('-')
55 if (parts[0] !== 'agt' || parts.length < 3) return null
56 const run = parts[1]
57 const role = parts.slice(2).join('-')
58 return SAFE_RUN.test(run) && ROLE_RE.test(role) ? { run, role } : null
59}
60
61export function doneFile(home, run, role) {
62 if (!home || !SAFE_RUN.test(run) || !ROLE_RE.test(role)) return null
63 return home + '/.agentille/state/run-' + run + '/done-' + role
64}
65
66// A pane id made filename-safe: herdr w1:p3 → w1-p3, tmux %5 → 5. null when nothing safe is left.
67// The key names a pane instance's own answer file, so a respawned role never reads its predecessor's.
68export function paneKey(id) {
69 const key = String(id ?? '').replace(/[^A-Za-z0-9_-]/g, '-').replace(/^-+|-+$/g, '')
70 return PANE_KEY.test(key) ? key : null
71}
72
73// Where a worker saves its full answer: one file per pane instance (pane-<role>.<key>.md). With no key,
74// the legacy pane-<role>.md an older worker writes. null for an unsafe run, role or key.
75export function answerFile(home, run, role, key = null) {
76 if (!home || !SAFE_RUN.test(run) || !ROLE_RE.test(role) || (key !== null && !PANE_KEY.test(key))) return null
77 return home + '/.agentille/state/run-' + run + '/agents/pane-' + role + (key === null ? '' : '.' + key) + '.md'
78}
79
80// ── /agt-spawn arguments ──────────────────────────────────────────────────────
81
82// `"task" --model haiku` → { task, model }. `--model` counts as the flag only when it stands
83// alone before a model-looking token; "explain the --model flag" stays prose. One matching pair of
84// surrounding quotes is stripped. { usage } for a missing task, { error } for a bad model or a
85// single bare word (claude would run it as a subcommand).
86export function parseSpawnArgs(args) {
87 let model = DEFAULT_MODEL
88 let bad = null
89 let taken = false
90 const rest = String(args ?? '').replace(/(^|\s)--model(?:\s+|=)(\S+)(?=\s|$)/g, (whole, _lead, m) => {
91 if (taken) return whole
92 if (MODEL_RE.test(m)) model = m
93 else if (MODEL_LIKE.test(m) && /[^a-z]/.test(m)) bad = m
94 else return whole
95 taken = true
96 return ''
97 }).trim()
98 if (bad) return { error: 'Unknown model "' + bad + '". ' + SPAWN_USAGE }
99 const task = rest.replace(/^(["'])((?:(?!\1)[\s\S])*)\1$/, '$2').trim()
100 if (BARE_WORD.test(task)) return { error: 'A one-word task would run as a claude subcommand. Say more. ' + SPAWN_USAGE }
101 return task ? { task, model } : { usage: SPAWN_USAGE }
102}
103
104// ── tmux ──────────────────────────────────────────────────────────────────────
105
106export const TMUX_LIST_FORMAT = ['#{pane_id}', '#{@agt}', '#{@agt_vendor}', '#{pane_dead}', '#{window_id}'].join(TAB)
107
108export function parseTmuxList(stdout) {
109 return String(stdout ?? '').split('\n').filter(Boolean).map((line) => {
110 const [id, agt, vendor, dead, window] = line.split(TAB)
111 return { id, agt: agt ?? '', vendor: vendor ?? '', dead: dead === '1', window: window ?? '' }
112 }).filter((r) => r.id)
113}
114
115// The agt- panes a lead looks at: not itself, and only in its own window. Without a known
116// self (its pane is missing from the list) nothing is window-scoped, so nothing is trusted.
117export function scopeRows(rows, selfPane) {
118 const me = rows.find((r) => r.id === selfPane)
119 return rows.filter((r) => r.agt.startsWith('agt-') && r.id !== selfPane && (!me || r.window === me.window))
120}
121
122// A done-file counts only if it was written once the pane was already known: a file left by
123// an earlier worker of the same role must not mark the new one done.
124export function isFreshDone(stat, since) {
125 return stat?.kind === 'file' && typeof stat.mtimeMs === 'number' && typeof since === 'number' && stat.mtimeMs >= since
126}
127
128// /agt-spawn panes are the person's, not a run's: shown as `open`, never as working or done,
129// so they feed no spinner, busy timer or working count.
130export function quietSpawn(panes) {
131 return panes.map((p) => (p.role === SPAWN_ROLE ? { ...p, state: 'open', seq: 0 } : p))
132}
133
134// tmux rows → live.js pane agents. `done` maps pane name → whether a fresh done-file exists.
135// A pane is done when that holds or tmux says it is dead; seq is synthesized for reapPlan().
136export function tmuxPaneAgents(rows, selfPane, done) {
137 return quietSpawn(scopeRows(rows, selfPane).map((r) => {
138 const n = splitName(r.agt)
139 const finished = r.dead || done.get(r.agt) === true
140 return { id: r.id, kind: 'pane', name: r.agt, role: n?.role ?? r.agt, run: n?.run ?? '', vendor: r.vendor || 'claude', state: finished ? 'done' : 'working', seq: finished ? 1 : 0, tab: r.window, workspace: null }
141 }))
142}
143
144// Only a pane with no @agt name of its own is a lead; workers never reap.
145export function isLead(rows, selfPane) {
146 const me = rows.find((r) => r.id === selfPane)
147 return !!me && !me.agt.startsWith('agt-')
148}
149
150export const TMUX_LIST_ARGV = ['tmux', 'list-panes', '-a', '-F', TMUX_LIST_FORMAT]
151
152// ── reaping ───────────────────────────────────────────────────────────────────
153
154// /agt-spawn panes belong to the person, not to a run: never offered to the reaper.
155export function reapPool(panes) {
156 return panes.filter((p) => p.role !== SPAWN_ROLE)
157}
158
159// ── spawning ──────────────────────────────────────────────────────────────────
160
161export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
162const effortArgs = (effort) => (EFFORTS.includes(effort) ? ['--effort', effort] : [])
163// A routed worker runs as its agentille agent definition (instructions, tools, verdict format),
164// not as a bare session that only has the task prompt.
165const agentArgs = (agent) => (ROLES.includes(agent) ? ['--agent', 'agentille:agentille-' + agent] : [])
166
167// Claude Code's advisor attaches to every session from the user's settings. With
168// profile.routing.advisorOnOpus false, an Opus or Fable worker runs without one.
169export const advisorEnv = (model, settings) => (settings?.advisorOnOpus === false && (model === 'opus' || model === 'fable') ? ['CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1'] : [])
170
171// Who the worker is, for its own mascot band: <agent>:<model>:<effort>. A typed /agt-spawn has no agent.
172export const workerEnv = ({ agent, model, effort }) => 'AGENTILLE_WORKER=' + [agent || SPAWN_ROLE, model, EFFORTS.includes(effort) ? effort : ''].join(':')
173
174export function tmuxSplitArgv({ target, cwd, run, shell, model, name, task, effort, direction, agent, env = [] }) {
175 const head = ['tmux', 'split-window', '-d', direction === 'down' ? '-v' : '-h', '-P', '-F', '#{pane_id}', '-t', target, '-c', cwd.replace(/#/g, '##'), '-e', 'AGENTILLE_RUN=' + run, '-e', workerEnv({ agent, model, effort }), ...env.flatMap((v) => ['-e', v])]
176 // `--` ends claude's options, so a task that starts with `-` is still just the prompt
177 const tail = [...agentArgs(agent), '--model', model, ...effortArgs(effort), '-n', name, '--', task]
178 const base = String(shell ?? '').split('/').pop()
179 if (base === 'zsh' || base === 'bash') return [...head, shell, '-ic', 'claude "$@"', 'agt', ...tail]
180 return [...head, 'claude', ...tail]
181}
182
183// Stacked layout: the first worker splits right of the lead, each later one splits down from the
184// newest worker still live. No width probing: a stack always has room for one more row.
185export function splitPlan({ lead, opened = [], live = [] }) {
186 const alive = opened.filter((o) => live.some((p) => p.id === o.id))
187 return alive.length ? { target: alive[alive.length - 1].id, direction: 'down' } : { target: lead, direction: 'right' }
188}
189
190export const tmuxEvenArgv = (id) => ['tmux', 'select-layout', '-E', '-t', id]
191
192export function tmuxTagArgvs(id, name) {
193 return [
194 ['tmux', 'set-option', '-p', '-t', id, '@agt', name],
195 ['tmux', 'set-option', '-p', '-t', id, '@agt_vendor', 'claude'],
196 ['tmux', 'set-option', '-p', '-t', id, 'allow-set-title', 'off'],
197 ['tmux', 'select-pane', '-t', id, '-T', name],
198 ]
199}
200
201export function tmuxPaneIdOf(stdout) {
202 const id = String(stdout ?? '').trim()
203 return /^%\d+$/.test(id) ? id : null
204}
205
206export const tmuxKillArgv = (id) => ['tmux', 'kill-pane', '-t', id]
207
208// herdr: split beside the lead (never focused), start claude in it, then prompt it.
209export function herdrSplitArgv({ pane, cwd, run, direction, agent, model, effort, env = [] }) {
210 return ['herdr', 'pane', 'split', '--pane', pane, '--direction', direction === 'down' ? 'down' : 'right', '--cwd', cwd, '--env', 'AGENTILLE_RUN=' + run, '--env', workerEnv({ agent, model, effort }), ...env.flatMap((v) => ['--env', v]), '--no-focus']
211}
212
213export function herdrPaneIdOf(stdout) {
214 try {
215 return JSON.parse(stdout).result?.pane?.pane_id ?? null
216 } catch {
217 return null
218 }
219}
220
221export const herdrStartArgv = ({ name, pane, model, effort, agent }) => ['herdr', 'agent', 'start', name, '--kind', 'claude', '--pane', pane, '--timeout', '45000', '--', ...agentArgs(agent), '--model', model, ...effortArgs(effort)]
222export const herdrPromptArgv = (name, task) => ['herdr', 'agent', 'prompt', name, task]
223export const herdrCloseArgv = (id) => ['herdr', 'pane', 'close', id]
224
225// Herdr's sidebar label for a worker pane (display-only; a failure changes nothing).
226export function herdrMetaArgv(pane, { display = null, title = null } = {}) {
227 // herdr reads the pane id before the options
228 const argv = ['herdr', 'pane', 'report-metadata', pane, '--source', 'agentille']
229 if (display) argv.push('--display-agent', String(display).slice(0, 48))
230 if (title) argv.push('--title', String(title).slice(0, 64))
231 return argv.length > 6 ? argv : null
232}
233
234// Jump to a worker from its band row: Herdr focuses the agent by name, tmux selects the pane.
235export function focusArgv(transport, pane) {
236 if (transport === 'herdr' && pane?.name) return ['herdr', 'agent', 'focus', pane.name]
237 if (transport === 'tmux' && /^%\d+$/.test(String(pane?.id ?? ''))) return ['tmux', 'select-pane', '-t', pane.id]
238 return null
239}
240
241// Whether a routing role runs as a pane worker: only an executor or the adversary. Reviewers and the
242// planning roles stay subagents: their flags, token counts and full answers exist only there.
243export function paneRole(agent) {
244 return agent === 'executor' || agent === 'adversary'
245 ? { pane: true, why: '' }
246 : { pane: false, why: `${agent} runs as a subagent: only an executor or the adversary gets a pane.` }
247}
248
249// ── pane tools: /agt panes mode opens and closes workers through the mod ──────
250
251// Fable is not offered: on a tool it would skip the routing guard. A typed /agt-spawn may still pick it.
252export const TOOL_MODELS = ['sonnet', 'opus', 'haiku']
253
254export const SPAWN_TOOL = {
255 name: 'spawn_pane',
256 isDeferred: false,
257 description: 'Open one agentille worker pane beside this session (Herdr or tmux, whichever is live), never focused, named agt-<run>-<role>, running Claude on the model and effort agentille routes for `agent`, with the task as its first prompt. Use it for each panes-mode slice in place of raw herdr/tmux commands. The pane is reaped once it sits done; close it yourself with close_pane after harvesting.',
258 inputSchema: {
259 type: 'object',
260 properties: {
261 run: { type: 'string', description: 'The run id from the [agt run=…] header (6 chars).' },
262 role: { type: 'string', description: 'Slice role, lowercase letters, digits and dashes (exec-1, review). Not "spawn".' },
263 task: { type: 'string', description: 'The full self-contained worker prompt.' },
264 agent: { type: 'string', enum: ROLES, description: 'Routing role (executor, code-reviewer, …): the mod routes model and effort from it.' },
265 header: { type: 'string', description: 'This run\'s full [agt run=… size=… risk=… mode=… fable=…] line.' },
266 model: { type: 'string', enum: TOOL_MODELS, description: 'Ignored: the mod routes model and effort from agent + header. Kept so older prompts validate.' },
267 cwd: { type: 'string', description: 'Absolute directory to start in (the slice worktree). Default: this session\'s directory.' },
268 },
269 required: ['run', 'role', 'task', 'agent', 'header'],
270 additionalProperties: false,
271 },
272}
273
274export const CLOSE_TOOL = {
275 name: 'close_pane',
276 isDeferred: false,
277 description: 'Close one agentille worker pane this session opened (an agt-<run>-<role> pane in this tab or window) after you have read its result. Panes opened by a typed /agt-spawn and panes of other sessions are refused.',
278 inputSchema: {
279 type: 'object',
280 properties: { name: { type: 'string', description: 'The pane name, agt-<run>-<role>.' } },
281 required: ['name'],
282 additionalProperties: false,
283 },
284}
285
286// spawn_pane input → { run, role, name, agent, header, hdr, asked, model, task, cwd } or { error }. `live` is the
287// panes the mod sees now; a name already on screen is refused rather than doubled.
288export function spawnToolInput(input, live = []) {
289 const i = input ?? {}
290 const str = (v) => (typeof v === 'string' ? v.trim() : '')
291 const run = str(i.run)
292 const role = str(i.role)
293 const task = str(i.task)
294 const agent = str(i.agent)
295 const header = str(i.header)
296 const asked = str(i.model) || null
297 const model = asked ?? DEFAULT_MODEL
298 const cwd = i.cwd === undefined ? null : str(i.cwd)
299 if (!SAFE_RUN.test(run)) return { error: 'run must be the run id from the [agt run=…] header.' }
300 if (!ROLE_RE.test(role)) return { error: 'role must be lowercase letters, digits and dashes.' }
301 if (role === SPAWN_ROLE) return { error: '"spawn" is reserved for a typed /agt-spawn.' }
302 const name = paneName(run, role)
303 if (!name) return { error: 'agt-' + run + '-' + role + ' is not a valid pane name (max 32 chars).' }
304 if (!ROLES.includes(agent)) return { error: 'agent must be one of ' + ROLES.join(', ') + '.' }
305 const hdr = parseHeader(header)
306 if (!hdr) return { error: 'header must be the run\'s [agt run=… …] line.' }
307 if (hdr.run !== run) return { error: 'header run=' + hdr.run + ' does not match run ' + run + '.' }
308 if (!TOOL_MODELS.includes(model)) return { error: 'model must be one of ' + TOOL_MODELS.join(', ') + '. Fable runs only through the routing guard or a typed /agt-spawn.' }
309 if (!task) return { error: 'task is empty.' }
310 if (BARE_WORD.test(task)) return { error: 'A one-word task would run as a claude subcommand. Send the full worker prompt.' }
311 if (cwd !== null && (!cwd.startsWith('/') || cwd.includes('\0'))) return { error: 'cwd must be an absolute path.' }
312 if (live.some((p) => p.name === name)) return { error: name + ' is already open. Pick another role or close it first.' }
313 return { run, role, name, agent, header, hdr, asked, model, task, cwd }
314}
315
316// close_pane input → { pane } from the panes this session owns, or { error }. `tab`, when
317// given, keeps a herdr close inside the lead's own tab.
318export function closeTarget(input, live = [], tab = null) {
319 const name = typeof input?.name === 'string' ? input.name.trim() : ''
320 const n = splitName(name)
321 if (!n) return { error: 'name must be an agt-<run>-<role> pane name.' }
322 if (n.role === SPAWN_ROLE) return { error: name + ' was opened by a typed /agt-spawn; it is the person\'s to close.' }
323 const pane = live.find((p) => p.name === name && (tab === null || p.tab === tab))
324 return pane ? { pane } : { error: 'No pane named ' + name + ' in this tab or window.' }
325}
326hooks/skew.js 36 lines1// Version skew: a session keeps running the agentille it started with, so a plugin update
2// leaves it on old code (no wire, an old reaper). The plugin cache keeps one directory per
3// installed version beside the running one: <cache>/<marketplace>/agentille/<x.y.z>. Pure.
4
5const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/
6
7export function parseVersion(v) {
8 const m = VERSION_RE.exec(String(v ?? '').trim())
9 return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
10}
11
12// > 0 when a is newer than b; null when either is not x.y.z.
13export function compareVersions(a, b) {
14 const x = parseVersion(a)
15 const y = parseVersion(b)
16 if (!x || !y) return null
17 for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i]
18 return 0
19}
20
21// The newest x.y.z among the directory names beside the running plugin, when newer than `running`.
22export function newerInstalled(running, names) {
23 let best = null
24 for (const n of names ?? []) if (compareVersions(n, running) > 0 && (best === null || compareVersions(n, best) > 0)) best = n
25 return best
26}
27
28// The directory holding the running plugin's version directories, from its own root.
29export function cacheDirOf(root) {
30 const s = String(root ?? '').replace(/\/+$/, '')
31 const at = s.lastIndexOf('/')
32 return at > 0 ? s.slice(0, at) : null
33}
34
35export const skewMessage = (installed, running) => 'agentille v' + installed + ' is installed but this session runs v' + running + ' — restart Claude Code before opening panes'
36hooks/tree.js 99 lines1// The nested agent tree, as plain data. The engine's agent list ($.agent.list) is the truth for
2// who exists, who spawned whom and where each loop stands; this file merges it into the live
3// map and orders rows depth-first. register.js reads the list and paints.
4
5import { finish, newAgent } from './live.js'
6import { roleOf } from './routing.js'
7
8const ENDED = new Set(['completed', 'failed', 'killed'])
9const BROKEN = new Set(['failed', 'killed'])
10// A workflow agent unheard from this long is over: a cancelled one raises no turn.complete, and no
11// list names it. One model request rarely runs past a few minutes; a tool call or step resets it.
12export const WORKFLOW_SILENT_MS = 10 * 60_000
13
14const typeName = (t) => String(t ?? 'agent').split(':').pop()
15
16// Rebuild a row for every listed agent the mod has no record of (a module reload loses the live
17// map). Never overwrites a known agent; ended agents are not worth a row. Model, effort and
18// tokens stay empty: the list does not carry them, and nothing is guessed.
19export function adopt(live, list, { run, now }) {
20 const added = []
21 for (const item of list ?? []) {
22 // A fork the mod skips at spawn (register.js) stays skipped here.
23 if (!item?.id || live.has(item.id) || ENDED.has(item.status) || item.type === 'fork') continue
24 const a = newAgent({ id: item.id, role: roleOf(item.type) ?? typeName(item.type), routed: false, model: null, effort: null, reason: null, run, now })
25 a.parentId = item.parentId ?? null
26 a.listStatus = item.status
27 a.adopted = true
28 live.set(item.id, a)
29 added.push(a)
30 }
31 return added
32}
33
34// Fold the list's view of each known agent into the live map. A failed or killed agent raises no
35// turn.complete, so it is finished here. So is an adopted agent that completed or is idle (nothing
36// else ends it, and a working agent keeps the ticker and the list poll alive), and any agent the
37// list once named and no longer does. Returns whether anything changed.
38export function applyStatus(live, list, now) {
39 const byId = new Map((list ?? []).map((item) => [item.id, item]))
40 let changed = false
41 for (const a of live.values()) {
42 const item = byId.get(a.id)
43 if (!item) {
44 if ((a.adopted || a.listed) && a.state === 'working') { finish(a, now); changed = true }
45 continue
46 }
47 const parentId = a.parentId ?? item.parentId ?? null
48 if (a.listStatus !== item.status || a.parentId !== parentId || !a.listed) changed = true
49 a.listStatus = item.status
50 a.parentId = parentId
51 a.listed = true
52 if (a.state !== 'working') continue
53 if (BROKEN.has(item.status) || (a.adopted && (item.status === 'completed' || item.status === 'idle'))) { finish(a, now); changed = true }
54 }
55 return changed
56}
57
58// Finish every working workflow agent nothing has been heard from in WORKFLOW_SILENT_MS. Returns
59// whether any was.
60export function quietWorkflows(live, now) {
61 let changed = false
62 for (const a of live.values()) {
63 if (!a.workflow || a.state !== 'working' || a.calls > 0 || now - (a.heard ?? a.start) < WORKFLOW_SILENT_MS) continue
64 finish(a, now)
65 changed = true
66 }
67 return changed
68}
69
70// Depth-first order with a tree prefix per row: "├─", "│ └─". A row whose parent is not among the
71// rows (the lead, a pane, a parent already gone) is a root. Prefixes are padded to one width so
72// the columns after them stay in line.
73export function nest(rows) {
74 const ids = new Set(rows.map((r) => r.id))
75 const kids = new Map()
76 const roots = []
77 for (const r of rows) {
78 if (r.parentId && r.parentId !== r.id && ids.has(r.parentId)) kids.set(r.parentId, [...(kids.get(r.parentId) ?? []), r])
79 else roots.push(r)
80 }
81 const out = []
82 const seen = new Set()
83 const walk = (list, lead) => {
84 list.forEach((r, i) => {
85 if (seen.has(r.id)) return
86 seen.add(r.id)
87 const last = i === list.length - 1
88 out.push({ ...r, tree: lead + (last ? '└─' : '├─') })
89 walk(kids.get(r.id) ?? [], lead + (last ? ' ' : '│ '))
90 })
91 }
92 walk(roots, '')
93 // A parent cycle never reaches a root: show those rows flat rather than lose them.
94 const loose = rows.filter((r) => !seen.has(r.id))
95 loose.forEach((r, i) => out.push({ ...r, tree: i === loose.length - 1 ? '└─' : '├─' }))
96 const width = Math.max(0, ...out.map((r) => r.tree.length))
97 return out.map((r) => ({ ...r, tree: r.tree.padEnd(width) }))
98}
99