SLOPSHOPPER

agentille

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.

newpanebandspinnerrowsguard
★ 1v3.9.0MITupdated 2026-10-08hasuwini77/agentille
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agentille
│ ┃ agentille ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ agentille run adhoc ◇0 ▣0 │ ┃ No agents yet. Type /agt "task": every ⏺ Read(src/auth.ts) │ ┃ dispatch shows up here. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agt-deck │ ⎿ agentille: deck open │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · agentille
◆ agentille run adhoc ◇0 ▣0 No agents yet. Type /agt "task": every dispatch shows up here.
README

agentille

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.

When to use it

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 fitExample
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.

Options

You typeYou 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-ledgerRouting 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-mdSeed a repo's CLAUDE.md · tune an existing one

How it decides

  • Solo for a small, one-sentence task. Subagents for sequential work. Panes (Herdr or tmux) only when 2+ slices can build at once; each worker pane shows a small mascot that says hi, walks while it works and waves bye. The pick is always printed with a one-line reason.
  • The switchboard: a framed dispatch tree above your prompt, rooted at your session, under one swarm line that holds every agent of the run by phase (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.
  • In the transcript and under the prompt: each 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.
  • Never loses a worker's answer: a pane is closed only once its answer is saved or read, a pane that finishes silently wakes your session, and /agt refuses to open panes when the session runs an older agentille than the one installed (restart first).
  • Type less: /agt -- completes its flags and their values.
  • The wire: pane workers report to your session when they finish, so it wakes on its own; their live status reaches your band through a shared store.
  • Best with Herdr: workers are labeled in Herdr's sidebar and a row's ↗ jumps to its pane. tmux works with the same band; without a multiplexer you get the subagent tree.
  • Routing: planners and judgment reviewers on Opus, the executor on Sonnet (never Haiku), effort set per role by the bundled mod. Fable only after observed failures at Opus max, or when you type --fable.
  • Review is a gate: P0/P1 findings get fixed before the card says ✓.
  • Squads: in a saas, ecommerce, content or immersive repo, payments, SEO and performance specialists join the review when the diff touches their area.

Requirements

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.

Acknowledgments

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.

Source 15 files
hooks/register.js 1183 lines
1// 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}
1183
hooks/routing.js 103 lines
1// 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}
103
hooks/live.js 169 lines
1// 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}
169
hooks/board.js 272 lines
1// 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}
272
hooks/wire.js 130 lines
1// 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}
130
hooks/squads.js 49 lines
1// 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}
49
hooks/mascot.js 79 lines
1// 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}
79
hooks/highlight.js 233 lines
1// 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}
233
hooks/focus.js 53 lines
1// 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}
53
hooks/panes.js 326 lines
1// 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}
326
hooks/skew.js 36 lines
1// 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'
36
hooks/tree.js 99 lines
1// 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