SLOPSHOPPER

herdr-agents

Run worker agents (opencode, codex, claude) in herdr tabs with any provider; their reports come back to this session.

newpanebandrowsguardcommand
★ 2v0.13.0MITupdated 2026-10-26contursofran/claude-herdr-orchestrator
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · herdr-agents
│ ┃ herdr ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ 0 busy wrap up ctx 97k 49% ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ issues ⏺ Update(src/auth.ts) │ ┃ plan build ⎿ Added 2 lines, removed 1 line │ ┃ rev CI you ⏺ Bash(bun test) │ ┃ No workers yet. ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /hrd-config │ ┃ ⎿ herdr-agents: Settings are above the prompt: alt+d to move into │ ┃ │ ┃ needs you 0 │ ┃ Nothing waits for you. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ───────────────────────────────────────── │ ┃ ──────────── │ ┃ Edit settings for Workers to keep busy Compact the coordinator past State file Prune merged worktrees every Close idle worker tabs after Command prefix Wrap up Show workers in Planner Builder always on Reviewer Debugger Small tasks go to ~/.claude/herdr-agents.json Done alt+d to enter · Enter to change · ↑↓ to move · Esc back to the prompt ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Edit settings for Workers to keep busy Compact the coordinator past State file Prune merged worktrees every Close idle worker tabs after Command prefix Wrap up Show workers in Planner Builder always on Reviewer Debugger Small tasks go to ~/.claude/herdr-agents.json Done alt+d to enter · Enter to change · ↑↓ to move · Esc back to the prompt
Pane · herdr
0 busy wrap up ctx 97k 49% issues plan build rev CI you No workers yet. needs you 0 Nothing waits for you. ─────────────────────────────────────────────────────
README

claude-herdr-orchestrator

Run a team of coding agents from one Claude Code session. The coordinator plans the work, starts workers (Claude, opencode, codex) in herdr tabs, and their reports come back to it as prompts. You answer the questions only you can answer from a panel above the prompt.

The panel above the prompt: a to-do for you, a decision with its options, and the workers

Install it from the plugin marketplace, at a Claude Code prompt (details under Install):

/plugin install herdr-agents --marketplace contursofran/claude-herdr-orchestrator

Features

Workers in herdr tabs, on any model

The coordinator starts each worker in its own herdr tab, usually in its own git worktree, with a profile: the built-in planner, builder, reviewer and debugger, or your own on Claude, opencode or codex (smallTasks sends the small ones to a cheap model). A worker's first-run "trust this folder?" prompt is answered for it. Each worker's tab gets HERDR_AGENTS_ROLE=worker; the mod also loads in a Claude worker's own session, which runs none of its timers; for the workers it started, it sweeps and checks their reports only while one of them runs.

Reports come back by themselves

When a worker finishes, its RESULT.md comes back to the coordinator as a prompt; no polling, no copying. It shows in the chat as one dim line (ctrl+o for the full report). Workers start RESULT.md with a short header (status, PR, a 3-line summary); the coordinator gets the summary and the file's path, and reads the rest only when it needs to. Reports without a header are shortened to a pointer when long. herdr tells the mod the moment a worker ends its turn (herdr agent wait), and a RESULT.md is delivered within 3 seconds of landing, even while its worker is still finishing its turn (a worker with messages held for it waits for the end of its turn). A sweep each minute, with one herdr agent list for all workers, catches anything missed; a single failed herdr call never ends a job. Reports, your answers and ticked to-dos that arrive within a few seconds, or while the coordinator is busy, reach it as one prompt.

To-dos pinned for you

What only you can do (test a build, merge a PR, restart something) is pinned at the top of the panel, so it doesn't scroll away in a report. Tick Done and the coordinator carries on from it; it unpins what is no longer needed.

A to-do pinned for the owner, with a link to the issue and a Done button

Decisions, with options and a recommendation

Questions that are yours (a product rule, a design choice, an approval) wait on the panel with their options and the recommended one starred, and herdr pings you. Answer with 1–4 from an empty prompt, with alt+d and the arrows, or with a click; o gives your own answer. A #12 links to that issue or PR.

A decision with three options, the recommended one starred

Only your questions reach you

Workers ask with DECISION: lines. The coordinator settles the ones that aren't yours, and the panel shows each answer with a ✓ for ten minutes, so you can overrule it.

Every worker at a glance

Role, time and state for each worker: working, waiting on you, done. Four fit in the compact view; Details shows them all. A worker waiting on CI or a deploy leaves a WAIT.json with a check to run; the mod runs it and wakes the worker when it passes, with no model running meanwhile (⏸ and what it waits for).

The workers: two builders and a reviewer waiting on a deploy, with the coordinator's context use

A sidebar beside the chat

/hrd-side docks the same things beside the transcript, in grey and white. At the top are the issues, one row per issue or PR. The planner, the builders and fix rounds, and the reviewer of the PR a builder linked share that row; the same number in two repos is two rows. A row whose work joins another row (a review that started before the builder's report named its PR) says so in its slot until clear done. A row's number opens its issue or PR on GitHub. Each row has a mark for plan, build, review, CI and you: ✓ done, ◐ running, ⏸ waiting, · not started, ? or ☐ waiting on you, ■ blocked. CI comes from the PR's checks, read with gh every two minutes while the sidebar is open. What needs you sits at the bottom, level with the prompt, over a box of fixed height for the item you pick; with nothing waiting it stays empty. For a question the box shows the answers, the recommended one first. For a to-do it shows Done and any link in the text.

The sidebar's background is Claude Code's composerSidebarBackground theme color. To match a terminal's background, add a custom theme in ~/.claude/themes/ (for example { "name": "Dark", "base": "dark", "overrides": { "composerSidebarBackground": "#101010" } }) and pick it with /theme. The line between the chat and the sidebar is Claude Code's resize grip.

Rows never move on their own. An answered or finished row stays in its slot, struck through, until you press clear done. A new one takes the next free slot. Answering swaps the box's contents in place, and the box stays on the answer until you press next. While the sidebar shows, the panel above the prompt steps aside. To start every session with the sidebar instead of the panel, set Show workers in to the sidebar in /hrd-config ("view": "side").

Auto-compact at 200k

The panel shows the coordinator's context (ctx 75k / 200k); the sidebar shows it as a share of that limit (ctx 75k 38%). Past resetAt (200k tokens) the coordinator writes its state to COORDINATOR-STATE.md and is compacted, then reads it back before the next report, so a session can run all day.

A playbook that keeps workers busy

Planner, then builder, then reviewer; each fix round goes to a fresh builder, and the debugger takes over after two failed rounds. The coordinator is reminded when fewer than target workers run.

Wrap-up and cleanup

/hrd-wrap-up, or the panel's Wrap up button, starts no new workers and lets the running ones finish; herdr notifies you when the last one is done. Worktrees whose PR merged are removed every half hour, with their tabs and branches. A worker's tab closes once it has sat idle half an hour after its report, unless it has a question open; a follow-up after that goes to a fresh worker.

Settings in a list

/hrd-config lays the settings out above the prompt, like Claude Code's /config: for all folders or just this one.

/hrd-config: the settings listed above the prompt

The board for other apps

What the sidebar shows is also kept in ~/.herdr-agents/board/<session id>.json, rewritten when it changes and removed when the session ends, so an app outside Claude Code (such as contr, a native herdr client) can draw it. The app presses the sidebar's buttons by dropping a JSON file in <session id>.actions/ beside it: answer a decision, tick or dismiss a to-do, clear done, wrap up or keep going. The coordinator gets the same prompts as from a click in the sidebar. The format is in docs/board-schema.json.

What's in the repo

  • herdr-agents (the mod): the spawn, message and stop tools for workers, ask_owner, decided, pin_for_owner and unpin_for_owner for the panel, and everything above.
  • herdr-agents-view (view/): draws worker reports as one dim line. It has to be a separate mod, because Claude Code never raises a mod's own ui.render hooks for the prompts that mod submits.
  • Subagents (agents/, shipped with the mod as herdr-agents:planner, herdr-agents:builder and herdr-agents:reviewer): planner (Opus high), builder (Haiku xhigh) and reviewer (Opus high); the debugger profile is Opus with full tools. They hold no repo rules of their own: they read the repo's AGENTS.md/CLAUDE.md and its .claude/skills checklists.

Install

You need herdr and Claude Code. At the prompt of a Claude Code session:

/plugin install herdr-agents --marketplace contursofran/claude-herdr-orchestrator
/plugin install herdr-agents-view@claude-herdr-orchestrator

Answer y to add the marketplace, then pick the user scope. The first installs the mod and its planner, builder and reviewer subagents; the second draws worker reports as one line. Start new sessions inside herdr to use it.

The first time the coordinator uses one of the mod's tools (spawn, ask_owner, …), Claude Code asks; choose "don't ask again", or allow them all at once in ~/.claude/settings.json:

{ "permissions": { "allow": ["mcp__herdr-agents"] } }

Optional: bind alt+d to move into the panel (ctrl+x tab works without it) in ~/.claude/keybindings.json:

{ "bindings": [{ "context": "Chat", "bindings": { "alt+d": "abovePrompt:focus" } }] }

Updates

Each release bumps the version in plugin.json. Run claude plugin update herdr-agents@claude-herdr-orchestrator (and the same for herdr-agents-view), or update them from /plugin, then start a new session. Watch the repository's releases on GitHub to hear about new versions.

Use it in a repo

  1. Rules. Add the section in templates/AGENTS.md to the repo's AGENTS.md (or CLAUDE.md) and fill it in: base branch, checks, how to update a branch, commit identity, who merges, what needs you. Every worker reads it, so this is where repo-specific rules go, not the subagents.
  2. Settings (optional). A .claude/herdr-agents.json in the folder you coordinate from overrides ~/.claude/herdr-agents.json for that folder, e.g. {"target": 2}.
  3. Start. Inside herdr, open the repo folder and run claude --model opus --effort medium, then /hrd-orchestrate <goal>, e.g. /hrd-orchestrate the issues labelled ready. The session gets the coordinator playbook (state file, worktree per worker, planner → builder → reviewer, when to ask you) and starts workers.
  4. Stop. /hrd-wrap-up (or the panel's Wrap up button) starts no new workers and lets the running ones finish; you get a herdr notification when the last one is done. /hrd-wrap-up off (Keep going) resumes.

One coordinator per folder: its notes live in COORDINATOR-STATE.md there, and wrap-up applies to the coordinators of that folder.

Commands

All start with hrd- (commandPrefix changes it).

CommandWhat it does
/hrd-orchestrate <goal>Make this session the coordinator, with the playbook and a goal.
/hrd-wrap-up, /hrd-wrap-up offStop starting workers and let the running ones finish; resume.
/hrd-sideOpen or close the sidebar beside the chat. ctrl+x tab moves into it; there Tab walks its buttons and Enter presses one. It draws no hotkeys, so it stays grey and white.
/hrd-decisionsList the questions and to-dos waiting for you. Answer on the panel: 1–4 from an empty prompt, or alt+d then arrows and Enter.
/hrd-workersList this session's workers.
/hrd-pruneRemove worktrees whose PR is merged (also runs every pruneMinutes).
/hrd-updatePull the clone this mod was installed from (git pull --ff-only), then run its install.sh again. It answers with the commits and version; running sessions reload the new version by themselves.
/hrd-configThe settings, laid out like /config, in the panel above the prompt: alt+d to move into them, ↑↓ to move, Enter to change (a switch flips, a choice steps on, a text opens a field), Done or /hrd-config again to close. The first row picks the file: all folders or this folder. Profiles are edited in the file.
/hrd-help, /hrd-hA short guide: the commands, the panel's keys, and the profiles and settings in effect for this folder.

Settings

Change them with /hrd-config, or by hand in ~/.claude/herdr-agents.json (in $CLAUDE_CONFIG_DIR when set), with <folder>/.claude/herdr-agents.json over it (profiles merge by name):

KeyDefaultMeaning
target4Workers the coordinator keeps busy.
resetAt200000Context tokens past which a coordinator saves its state and is compacted.
statePathCOORDINATOR-STATE.mdThe state file, relative to the folder (or absolute, or ~/…).
trustRootsthe folder and its parentFolders where a worker's first-run "trust this folder?" prompt is answered for it.
commandPrefixhrd-What every slash command starts with. Read when the session starts.
profilessee belowWorker profiles by name; one here replaces the built-in of that name.
disablenoneBuilt-in profiles to turn off: any of planner (the builder plans first), reviewer (the coordinator reviews the PR itself), debugger (after two failed fix rounds the coordinator asks you). The builder always runs.
smallTasksnoneA profile the coordinator may give small, well-defined tasks, e.g. "deepseek".
playbooknoneA line, or a list of lines, added to the coordinator playbook, e.g. "Label every PR with the issue's area."
pruneMinutes30How often merged worktrees are pruned; 0 turns it off (/hrd-prune still works).
closeIdleMinutes30How long a worker's tab stays open, idle, after its report; 0 keeps it open.
reposfrom git remotesShort names for #123 links, e.g. {"web": "owner/website"}. The repos of the session's and workers' folders are known already, by name and by the part after the last dash (acme-client, client).
wrapUpofftrue wraps up every coordinator on the machine; /hrd-wrap-up off clears it.
viewbandWhere the workers and what needs you show: band above the prompt or side, the sidebar, opened at the start of each session. Only one shows; /hrd-side switches for the session.

Built-in profiles, the playbook's steps: planner, builder, reviewer (the subagents above) and debugger (Opus medium, full tools, for root-cause hunts and fixes that failed twice). A profile of the same name under profiles changes its model or flags and keeps its place in the playbook; /hrd-help marks it customized. Any other name is a profile of your own. A profile is {"kind": "claude" | "codex" | "opencode", "args": [...], "env": {...}, "note": "..."}; kind is the CLI herdr starts and args its flags. Other CLIs or models are a profile away:

{
  "profiles": {
    "deepseek": {
      "kind": "opencode",
      "args": ["--standalone"],
      "env": { "OPENCODE_CONFIG_CONTENT": "{\"model\": \"opencode-go/deepseek-v4.1-flash\"}" },
      "note": "opencode DeepSeek V4.1 Flash"
    },
    "codex": { "kind": "codex", "args": ["-m", "gpt-6.1-sol", "--approve-for-me"], "note": "codex GPT-6.1 Sol" }
  },
  "smallTasks": "deepseek",
  "disable": ["debugger"]
}

Develop

To run the mod from a clone instead of the marketplace (uninstall the marketplace copy first: two plugins of one name don't load):

git clone git@github.com:contursofran/claude-herdr-orchestrator.git
cd claude-herdr-orchestrator && npm install
./install.sh && npm run types   # once Claude Code has loaded the mod: the plugin API's types
npm run check                   # formatting, type check, both manifests, tests

install.sh copies both mods to ~/.claude/mods/, creates ~/.claude/herdr-agents.json if missing, and adds to ~/.claude/settings.json and keybindings.json what loads the mods (CLAUDE_CODE_PLUGIN_DIRS), allows their tools and binds alt+d. After the first install, start new Claude Code sessions: /reload-plugins does not pick up new plugin folders. From then on, running sessions reload each update by themselves, and /hrd-update pulls and installs from inside a session.

npm run check runs format:check, typecheck, validate and test, each also on its own; run it before opening a pull request. CI runs the same on every pull request except typecheck, which needs the API types a local Claude Code writes. main takes changes only through pull requests, and only once CI passes. After a change, run ./install.sh again and bump version in .claude-plugin/plugin.json so running sessions reload the mod and marketplace users get the update. For a version worth announcing, tag it and publish a GitHub release saying what changed (gh release create v<version>).

Layout

hooks/register.tsx wires the engine's events to the modules beside it:

ModuleWhat it does
config.tsThe settings files, the built-in profiles, the state file's path.
defaults.tsDefault settings and tuning values.
workers.tsspawn, message, stop: starting workers, watching them, delivering RESULT.md, WAIT.json waits.
report.tsThe report header workers write at the top of RESULT.md, and its parsing.
owner.tsQuestions (ask_owner, DECISION: lines) and to-dos (pin_for_owner) waiting for the person.
outbox.tsHolds the coordinator's prompts and sends the ones that arrive together as one.
coordinator.tsThe playbook, the worker target, wrap-up, and compacting the coordinator's context.
target.tsThe line after a report or a stop: how many workers are still to start, or that wrap-up holds new ones.
board.ts, band.tsxThe panel above the prompt: what it shows, and drawing it.
side.tsx, tasks.ts/hrd-side: the sidebar, and grouping workers into one row per issue or PR.
links.ts#123 links to the right repo, for the panel and the sidebar.
settings.tsx/hrd-config.
commands.ts, tools.tsThe slash commands and the coordinator's tools.
worktrees.ts, herdr.tsPruning merged worktrees; the herdr CLI.

Each module keeps its own state (atoms in $.state) and offers functions to the others. Tests live in tests/, one file per area over a shared harness.tsx.

Contributing

Ideas, issues and pull requests are welcome. Run npm run check before opening a PR.

License

MIT; see LICENSE.

Source 24 files
hooks/register.tsx 167 lines
1import type { Register } from 'claude-code'
2import { drawBand, shortenBrief } from './band'
3import { refresh, showContext } from './board'
4import { registerCommands, runCommand } from './commands'
5import { publishBoard, removeBoard } from './bridge'
6import { claimState, config, resetSettings } from './config'
7import { maybeReset } from './coordinator'
8import { CLOSE_IDLE_MINUTES, PR_POLL_MS, PRUNE_MINUTES, SWEEP_MS } from './defaults'
9import { restoreOutbox, retry, turnEnded, turnStarted } from './outbox'
10import { askOwner, noteReports, pinForOwner, pingAfterReports, settle, unpinForOwner } from './owner'
11import type { AskArgs, DecidedArgs, PinArgs } from './owner'
12import { session } from './session'
13import { drawSettings, trackFocus } from './settings'
14import { drawSide, pollPrs, SIDE, sideClosed, sideOpen, sideShown, syncSide, toggleSide } from './side'
15import { jobs, restoreJobs } from './state'
16import { registerTools } from './tools'
17import { checkReports, messageWorker, resumeWatching, spawnWorker, stopWorker, sweep } from './workers'
18import type { SpawnArgs } from './workers'
19import { pruneMerged } from './worktrees'
20
21// herdr-agents: a coordinator session starts worker agents in herdr tabs, and their
22// reports come back to it as prompts. This file only wires the engine's events to
23// the modules beside it.
24
25export const register: Register = on => {
26  on('session.start', async ($, e, next) => {
27    const started = await next(e)
28    // The mod loads in every worker's own Claude session too (same plugin dirs); spawn marks
29    // those tabs, and they run none of the coordinator's timers, only the sweep and the report
30    // check for the workers they started while one of them runs.
31    const isWorker = (await $.env.get('HERDR_AGENTS_ROLE')) === 'worker'
32    session.cwd = started.cwd
33    const settings = await config($)
34    if (typeof settings.commandPrefix === 'string') session.prefix = settings.commandPrefix
35    await registerTools($)
36    await registerCommands($)
37    const restored = await restoreJobs($)
38    if (restored > 0) $.ui.toast(`herdr-agents: ${restored} workers restored after a reload`)
39    const closeMinutes = settings.closeIdleMinutes ?? CLOSE_IDLE_MINUTES
40    resumeWatching($, Object.values(await jobs($)))
41    await restoreOutbox($)
42    if (isWorker) {
43      // A worker's own session sweeps the workers it started, but only while one of them runs:
44      // with no running job it makes no herdr call. Its report check does nothing with no running
45      // job either. The other timers stay with the coordinator.
46      const sweepIfRunning = async () => {
47        if (Object.values(await jobs($)).some(j => j.status === 'running')) await sweep($, closeMinutes)
48      }
49      void sweepIfRunning().catch(() => {})
50      $.clock.every(SWEEP_MS, () => {
51        void sweepIfRunning().catch(() => {})
52        retry($)
53      })
54      $.clock.every(3000, () => void checkReports($).catch(() => {}))
55      return started
56    }
57    void sweep($, closeMinutes).catch(() => {})
58    // view "side": the sidebar instead of the band, opened once there is room for it.
59    // A reload finds it open already.
60    if (settings.view === 'side' && !(await sideOpen($))) void toggleSide($).catch(() => {})
61    // The band, the sidebar and the bridge's inbox, every 3 s with no herdr calls: refresh
62    // reads the states the waits and the sweep last saw. The report check comes first, so a
63    // RESULT.md that landed is delivered at this tick.
64    $.clock.every(
65      3000,
66      () =>
67        void checkReports($)
68          .catch(() => {})
69          .then(() => refresh($))
70          .then(() => syncSide($))
71          .then(() => publishBoard($))
72          .catch(() => {}),
73    )
74    $.clock.every(PR_POLL_MS, () => void pollPrs($).catch(() => {}))
75    const pruneMinutes = settings.pruneMinutes ?? PRUNE_MINUTES
76    if (pruneMinutes > 0) $.clock.every(pruneMinutes * 60_000, () => void pruneMerged($))
77    // The sweep always runs: it is what catches a worker whose wait child died. Only its
78    // closeIdle step depends on closeIdleMinutes.
79    $.clock.every(SWEEP_MS, () => {
80      void sweep($, closeMinutes).catch(() => {})
81      retry($)
82    })
83    return started
84  })
85  on('session.end', async ($, e, next) => {
86    await removeBoard($).catch(() => {})
87    return next(e)
88  })
89
90  on('tool.call', { tool: 'mcp__herdr-agents__spawn' }, ($, e) => spawnWorker($, e as unknown as SpawnArgs))
91  on('tool.call', { tool: 'mcp__herdr-agents__message' }, ($, e) => messageWorker($, e as unknown as { worker: string; text: string }))
92  on('tool.call', { tool: 'mcp__herdr-agents__stop' }, ($, e) => stopWorker($, e as unknown as { worker: string }))
93  on('tool.call', { tool: 'mcp__herdr-agents__ask_owner' }, ($, e) => askOwner($, e as unknown as AskArgs))
94  on('tool.call', { tool: 'mcp__herdr-agents__decided' }, ($, e) => settle($, e as unknown as DecidedArgs))
95  on('tool.call', { tool: 'mcp__herdr-agents__pin_for_owner' }, ($, e) => pinForOwner($, e as unknown as PinArgs))
96  on('tool.call', { tool: 'mcp__herdr-agents__unpin_for_owner' }, ($, e) => unpinForOwner($, e as unknown as { ids?: string[] }))
97
98  // The session that writes the shared state file owns it (see resetSettings).
99  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
100    const out = await next(e)
101    await claimState($, e.file_path)
102    return out
103  })
104  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
105    const out = await next(e)
106    await claimState($, e.file_path)
107    return out
108  })
109
110  // A worker's DECISION: questions ping the person once the coordinator's turn
111  // reading that report ends, unless it settled them itself. The prompts held in the
112  // outbox (see outbox.ts) wait for the turn's end too.
113  on('turn.start', async ($, e, next) => {
114    if (e.agentId === undefined) {
115      noteReports(e.turnId, e.text)
116      turnStarted()
117    }
118    return next(e)
119  })
120  on('turn.complete', async ($, e, next) => {
121    const out = await next(e)
122    if (e.agentId === undefined) {
123      // turnEnded runs even when the ping throws: a busy flag left set would hold the outbox until a turn ends.
124      try {
125        await pingAfterReports($, e.turnId)
126      } finally {
127        await turnEnded($)
128      }
129    }
130    return out
131  })
132
133  // After each turn: show the context fill on the band and reset it past the limit.
134  on('session.measure', async ($, e, next) => {
135    const out = await next(e)
136    const tokens = e.context.tokens
137    if (tokens !== undefined) {
138      const { limit } = await resetSettings($)
139      await showContext($, tokens, limit)
140      void maybeReset($, tokens)
141    }
142    return out
143  })
144
145  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => next(shortenBrief(e)))
146
147  // The band above the prompt: the settings while /hrd-config has them open, else
148  // what waits for the person and the workers, unless the sidebar shows them.
149  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
150    session.bandId = e.requestId
151    return (await drawSettings($, e)) ?? ((await sideShown($)) ? next(e) : drawBand($, e, next))
152  })
153
154  // The sidebar (/hrd-side).
155  on('ui.render', { component: 'Pane', requestId: SIDE }, ($, e) => drawSide($, e))
156  on('ui.close', async ($, e, next) => {
157    if (e.id === SIDE) await sideClosed($)
158    return next(e)
159  })
160  on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
161    await trackFocus($, e.element)
162    return next(e)
163  })
164
165  on('command.run', async ($, e, next) => (await runCommand($, e.command, e.args)) ?? next(e))
166}
167
hooks/band.tsx 343 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
3import type { Row, View } from '../types'
4import { readBoard } from './board'
5import { toggleWrapUp } from './coordinator'
6import { SETTLED_MS, TODO_ROWS, WORKER_CELL, WORKER_LINES } from './defaults'
7import { ago, elapsed } from './format'
8import { linker } from './links'
9import { answered, choose, decisionTitle, openQuestions, pinnedItems, tick } from './owner'
10import { cmd } from './session'
11
12// Which decision the band shows, and whether Details lists every worker.
13const view = atom({ plugin: 'herdr-agents', key: 'view' } as const, { tab: 'workers', selected: null } as View)
14
15export type BandRender = RenderInput<'AbovePrompt'>
16export type ToolUseRender = RenderInput<'ToolUse'>
17
18// The band above the prompt. Wide: decisions listed on the left, the selected one on
19// the right with its answers, workers below. Under 120 columns the list folds into a
20// strip of titles above the question. Digits answer from an empty prompt; ctrl+x tab
21// lands on the recommended answer, so Enter picks it.
22export async function drawBand($: EngineInterface, e: BandRender, next: (e: BandRender) => Promise<RenderElement>): Promise<RenderElement> {
23  const { now, rows, context, target: want, wrapUp } = await readBoard($)
24  const open = await openQuestions($)
25  const { selected, workers: workerView } = await read($, view)
26  const items = await pinnedItems($)
27  const clock = await $.clock.now()
28  const recent = (await answered($)).filter(x => clock - x.at < SETTLED_MS)
29  if (e.props.hasSurvey || (rows.length === 0 && open.length === 0 && recent.length === 0 && items.length === 0)) return next(e)
30
31  const { Box, Button, Link, Text } = $.ui.resolve(e)
32  const { linkify, repoOf } = await linker($, rows, (href, text, key) => (
33    <Link key={key} href={href}>
34      {text}
35    </Link>
36  ))
37  // bodyColumns is the band's box; the border and padding take 4 of it.
38  const cols = Math.max(20, (e.props.bodyColumns ?? 100) - 4)
39  const isWide = cols >= 116
40  const rule = <Text color="subtle">{'─'.repeat(cols)}</Text>
41  const short = (from: string) => from.replace(/\s*\(.*$/, '').slice(0, 22)
42  const pick = (id: number) => update($, view, v => ({ ...v, selected: id }))
43
44  const waiting = new Map<string, string[]>()
45  for (const d of open) waiting.set(d.from, [...(waiting.get(d.from) ?? []), `D${d.id}`])
46  const rank = (r: Row) =>
47    waiting.has(r.name) ? 0 : r.state === 'blocked' || r.state === 'gone' ? 1 : r.state === 'working' ? 2 : r.state === 'done' ? 3 : 4
48  const sorted = [...rows].sort((x, y) => rank(x) - rank(y))
49  const look: Record<string, { label: string; color?: string; dim?: boolean }> = {
50    working: { label: 'Working', color: 'success' },
51    waiting: { label: 'Waiting', dim: true },
52    blocked: { label: 'Blocked · answer it in its tab', color: 'error' },
53    idle: { label: 'Idle', dim: true },
54    done: { label: 'Done · report pending', color: 'success' },
55    reported: { label: 'Reported', dim: true },
56    gone: { label: 'Tab gone', color: 'error' },
57  }
58  const width = Math.max(0, ...rows.map(r => r.name.length)) + 3
59  const running = rows.filter(r => r.state !== 'reported' && r.state !== 'waiting').length
60  // While wrapping up, every worker not yet reported counts, waiting ones too.
61  const left = rows.filter(r => r.state !== 'reported').length
62
63  // Compact (the default) shows the first WORKER_LINES lines of workers; Details shows them all.
64  const isList = workerView === 'list'
65  const toggleList = () => update($, view, ({ workers, ...v }) => (isList ? v : { ...v, workers: 'list' as const }))
66
67  // Two per line when the band is wide enough. Sorted, so the ones that need the person come first.
68  const perLine = cols >= 2 * WORKER_CELL + 3 ? 2 : 1
69  const cellWidth = perLine === 2 ? Math.floor((cols - 3) / 2) : cols
70  const onPage = isList ? sorted : sorted.slice(0, perLine * WORKER_LINES)
71  const hidden = sorted.length - onPage.length
72  const lines = Math.ceil(onPage.length / perLine)
73  const columns = perLine === 2 ? [onPage.slice(0, lines), onPage.slice(lines)] : [onPage]
74  // The header's mix of roles among the workers it counts, e.g. "3 builders · 1 reviewer".
75  const roles = new Map<string, number>()
76  for (const r of rows) if (r.state !== 'reported' && r.state !== 'waiting') roles.set(r.profile, (roles.get(r.profile) ?? 0) + 1)
77  const mix = [...roles]
78    .sort((x, y) => y[1] - x[1])
79    .map(([p, n]) => `${n} ${n > 1 && /er$/.test(p) ? `${p}s` : p}`)
80    .join(' · ')
81  const parked = rows.filter(r => r.state === 'waiting').length
82  const pwidth = Math.max(0, ...rows.map(r => r.profile.length)) + 2
83  const cell = (r: Row) => {
84    const ids = waiting.get(r.name)
85    const l = ids
86      ? { label: `Waiting for ${ids.join(', ')}`, color: 'claude', dim: false }
87      : r.state === 'waiting'
88        ? { label: `⏸ ${r.note ?? 'Waiting'}`, dim: true }
89        : (look[r.state] ?? { label: r.state, dim: true })
90    return (
91      <Text key={r.name} wrap="truncate-end">
92        <Text color={l.color} dimColor={l.dim}>
93          {'● '}
94        </Text>
95        <Text dimColor={l.dim}>{r.name.padEnd(width)}</Text>
96        <Text dimColor>{`${r.profile.padEnd(pwidth)}${elapsed((r.endedAt ?? now) - r.startedAt).padEnd(8)}`}</Text>
97        <Text color={ids ? 'claude' : undefined} dimColor={l.dim || !ids}>
98          {linkify(l.label, r.repo)}
99        </Text>
100      </Text>
101    )
102  }
103
104  // The person's to-dos and news (pin_for_owner), at the top.
105  const forYou = items.length ? (
106    <Box flexDirection="column">
107      {items.slice(0, TODO_ROWS).map(t => (
108        <Box key={`t${t.id}`} justifyContent="space-between" columnGap={2}>
109          <Text>
110            <Text color={t.kind === 'do' ? 'claude' : undefined} dimColor={t.kind === 'know'}>
111              {t.kind === 'do' ? '☐ ' : 'ℹ '}
112            </Text>
113            <Text bold={t.kind === 'do'}>{linkify(t.text, repoOf(t.from))}</Text>
114            <Text dimColor>
115              {`   T${t.id} · `}
116              {linkify(short(t.from))}
117              {` · ${ago(clock - t.at)}`}
118            </Text>
119          </Text>
120          <Button key={`tick-t${t.id}`} plain dimColor onPress={() => tick($, t)}>
121            {t.kind === 'do' ? 'Done' : 'Dismiss'}
122          </Button>
123        </Box>
124      ))}
125      {items.length > TODO_ROWS ? <Text dimColor>{`  +${items.length - TODO_ROWS} more · ${cmd('decisions')} lists them all`}</Text> : null}
126    </Box>
127  ) : null
128
129  const index = Math.max(
130    0,
131    open.findIndex(d => d.id === selected),
132  )
133  const shown = open[index]
134  // The recommended answer is always 1.
135  const options = shown
136    ? (shown.options ?? [])
137        .map((label, i) => ({ label, isRec: i === shown.recommended }))
138        .sort((x, y) => Number(y.isRec) - Number(x.isRec))
139        .slice(0, 4)
140    : []
141  const ctxColor = context
142    ? context.tokens >= context.limit * 0.95
143      ? 'error'
144      : context.tokens >= context.limit * 0.8
145        ? 'warning'
146        : undefined
147    : undefined
148
149  const detail = shown ? (
150    <Box flexDirection="column" flexGrow={1}>
151      <Box justifyContent="space-between">
152        <Text>
153          <Text bold color="claude">{`D${shown.id}  `}</Text>
154          <Text dimColor>{linkify(shown.from)}</Text>
155        </Text>
156        <Text dimColor>{`${ago(now - shown.at)}${ago(now - shown.at) === 'now' ? '' : ' ago'}`}</Text>
157      </Box>
158      <Box marginTop={1}>
159        <Text bold>{linkify(shown.question, repoOf(shown.from))}</Text>
160      </Box>
161      <Box flexWrap="wrap" columnGap={4} marginTop={1}>
162        {options.map((o, i) => (
163          <Button
164            key={`d${shown.id}-o${i}`}
165            plain
166            hotkey={String(i + 1)}
167            variant={o.isRec ? 'primary' : undefined}
168            autoFocus={i === 0 ? true : undefined}
169            onPress={() => choose($, shown, o.label)}
170          >
171            {o.isRec ? <Text bold color="claude">{`★ ${o.label}  (recommended)`}</Text> : o.label}
172          </Button>
173        ))}
174      </Box>
175      <Box columnGap={4} marginTop={options.length ? 1 : 0}>
176        <Button
177          key={`d${shown.id}-other`}
178          plain
179          hotkey="o"
180          dimColor
181          autoFocus={options.length === 0 ? true : undefined}
182          onPress={() => $.prompt.fill({ text: `D${shown.id}: `, mode: 'append' })}
183        >
184          {options.length ? 'Other answer' : 'Answer in chat'}
185        </Button>
186        {open.length > 1 ? (
187          <Button key="prev" plain hotkey="p" dimColor onPress={() => pick(open[(index - 1 + open.length) % open.length]!.id)}>
188            Previous
189          </Button>
190        ) : null}
191        {open.length > 1 ? (
192          <Button key="next" plain hotkey="n" dimColor onPress={() => pick(open[(index + 1) % open.length]!.id)}>
193            Next
194          </Button>
195        ) : null}
196      </Box>
197    </Box>
198  ) : null
199
200  // Wide: one two-line entry per decision, the selected one marked.
201  const list = (
202    <Box flexDirection="column" width={34} flexShrink={0}>
203      <Text dimColor>Decisions</Text>
204      {open.slice(0, 6).map(d => {
205        const isOn = d === shown
206        return (
207          <Button key={`pick-d${d.id}`} plain onPress={() => pick(d.id)}>
208            <Text bold={isOn} color={isOn ? 'claude' : undefined} dimColor={!isOn}>{`${isOn ? '▸' : ' '} D${d.id}  `}</Text>
209            <Text bold={isOn}>{decisionTitle(d).slice(0, 24)}</Text>
210            {'\n'}
211            <Text dimColor>{`      ${short(d.from)} · ${ago(now - d.at)}`}</Text>
212          </Button>
213        )
214      })}
215      {open.length > 6 ? <Text dimColor>{`  +${open.length - 6} more · n for next`}</Text> : null}
216    </Box>
217  )
218
219  // Narrow: the titles as a strip.
220  const strip = (
221    <Box flexWrap="wrap" columnGap={3} marginBottom={1}>
222      {open.map(d => (
223        <Button key={`pick-d${d.id}`} plain dimColor={d !== shown} onPress={() => pick(d.id)}>
224          <Text bold={d === shown} inverse={d === shown} color={d === shown ? 'claude' : undefined}>
225            {` D${d.id} ${decisionTitle(d).slice(0, 18)} `}
226          </Text>
227        </Button>
228      ))}
229    </Box>
230  )
231
232  // The right column's height: header, gap, the wrapped question, gap, answers, gap, actions.
233  const detailWidth = Math.max(20, cols - 34 - 5)
234  const detailLines = shown ? 3 + Math.ceil(shown.question.length / detailWidth) + (options.length ? 2 : 0) + 1 : 0
235  const divider = shown ? (
236    <Box flexDirection="column" flexShrink={0} marginX={2}>
237      <Text color="subtle">{Array.from({ length: Math.max(Math.min(open.length, 6) * 2 + 1, detailLines) }, () => '│').join('\n')}</Text>
238    </Box>
239  ) : null
240
241  return (
242    <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
243      {forYou}
244      {recent.map(x => (
245        <Text key={`s${x.id}`} wrap="truncate-end">
246          <Text color="success">{'✓ '}</Text>
247          <Text bold>{`D${x.id} ${x.title ?? x.question.slice(0, 40)}`}</Text>
248          <Text dimColor>{`  settled by ${x.by} ${ago(clock - x.at)}: `}</Text>
249          {linkify(x.answer, repoOf(x.from))}
250        </Text>
251      ))}
252      {items.length > 0 || recent.length > 0 ? rule : null}
253      {shown && isWide ? (
254        <Box>
255          {list}
256          {divider}
257          {detail}
258        </Box>
259      ) : null}
260      {shown && !isWide ? (
261        <Box flexDirection="column">
262          {open.length > 1 ? strip : null}
263          {detail}
264        </Box>
265      ) : null}
266      {shown ? rule : null}
267      <Box justifyContent="space-between" columnGap={3}>
268        <Text>
269          {open.length > 0 ? (
270            <Text>
271              <Text color="claude">● </Text>
272              <Text bold>{`${open.length} decision${open.length === 1 ? '' : 's'} waiting`}</Text>
273            </Text>
274          ) : null}
275          {open.length > 0 ? <Text color="subtle">{'   │   '}</Text> : null}
276          {wrapUp ? (
277            <Text color="warning" bold>{`⏸ Wrapping up · ${left} left`}</Text>
278          ) : (
279            <Text dimColor={open.length > 0}>{want ? `${running}/${want} workers` : `${running} worker${running === 1 ? '' : 's'}`}</Text>
280          )}
281          {mix ? <Text dimColor>{`: ${mix}`}</Text> : null}
282          {parked ? <Text dimColor>{`   ⏸ ${parked} waiting`}</Text> : null}
283        </Text>
284        <Box columnGap={3} flexShrink={0}>
285          {rows.length > 0 ? (
286            <Button key="workers" plain dimColor onPress={toggleList}>
287              {isList ? 'Compact' : hidden > 0 ? `Details +${hidden}` : 'Details'}
288            </Button>
289          ) : null}
290          <Button key="wrap-up" plain dimColor={!wrapUp} onPress={() => toggleWrapUp($, !wrapUp)}>
291            {wrapUp ? 'Keep going' : 'Wrap up'}
292          </Button>
293          {context ? (
294            <Text color={ctxColor} dimColor={!ctxColor} bold={ctxColor === 'error'}>
295              {`ctx ${Math.round(context.tokens / 1000)}k / ${Math.round(context.limit / 1000)}k${ctxColor === 'error' ? ' · resets soon' : ''}`}
296            </Text>
297          ) : null}
298        </Box>
299      </Box>
300      {onPage.length > 0 ? (
301        <Box columnGap={3}>
302          {columns.map((list, i) => (
303            <Box key={`col${i}`} flexDirection="column" width={cellWidth}>
304              {list.map(cell)}
305            </Box>
306          ))}
307        </Box>
308      ) : null}
309      {shown ? (
310        <Box justifyContent="space-between">
311          <Text dimColor>
312            {options.length ? `Empty prompt: 1–${options.length} answer  ·  ` : ''}
313            {`alt+d (or ctrl+x tab), then ${options.length ? 'Enter ★' : 'Enter: answer in chat'} / o / n / p  ·  Esc: prompt`}
314          </Text>
315          <Text dimColor>click works too</Text>
316        </Box>
317      ) : null}
318    </Box>
319  )
320}
321
322// spawn and message calls drawn with their brief cut to its first line: the full
323// text is in the worker's TASK.md or MESSAGE file, not the transcript.
324export function shortenBrief(e: ToolUseRender): ToolUseRender {
325  const field = e.props.tool === 'mcp__herdr-agents__spawn' ? 'task' : e.props.tool === 'mcp__herdr-agents__message' ? 'text' : undefined
326  const input = e.props.input as Record<string, unknown> | undefined
327  const text = field && input ? input[field] : undefined
328  if (typeof text !== 'string' || text.length <= 160) return e
329  const first = (text.split('\n').find(l => l.trim()) ?? '').replace(/^#+\s*/, '').slice(0, 100)
330  const where =
331    field === 'task' && typeof input?.cwd === 'string' && typeof input?.worker === 'string'
332      ? ` · full brief in ${String(input.cwd).split('/').pop()}/.herdr-agents/${input.worker}/TASK.md`
333      : ''
334  const cwd = typeof input?.cwd === 'string' ? `…/${String(input.cwd).split('/').pop()}` : undefined
335  return {
336    ...e,
337    props: {
338      ...e.props,
339      input: { ...input, ...(cwd ? { cwd } : {}), [field!]: `${first} … (${text.length.toLocaleString('en')} chars${where})` },
340    },
341  }
342}
343
hooks/board.ts 84 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3import type { Board, Links, Row } from '../types'
4import { config, target, wrappingUp } from './config'
5import { LINGER_MS, QUIET_MAX_MS } from './defaults'
6import { elapsed } from './format'
7import { lastStatus } from './herdr'
8import { openQuestions, pingQuiet } from './owner'
9import { session } from './session'
10import { jobs } from './state'
11
12// What the band shows of the workers, and the GitHub repo of each folder they used.
13const board = atom({ plugin: 'herdr-agents', key: 'board' } as const, { now: 0, rows: [] } as Board)
14const links = atom({ plugin: 'herdr-agents', key: 'links' } as const, { byCwd: {}, extra: {} } as Links)
15
16export function readBoard($: EngineInterface) {
17  return read($, board)
18}
19export function repoLinks($: EngineInterface) {
20  return read($, links)
21}
22
23// After each turn: the context fill, shown on the band.
24export async function showContext($: EngineInterface, tokens: number, limit: number) {
25  await update($, board, b => ({ ...b, context: { tokens, limit } }))
26}
27
28// Folders whose git remotes were read this load (see learnRepos).
29const scanned = new Set<string>()
30
31// The GitHub repo (owner/name) of each folder not seen yet, from its git remotes, and the
32// config's `repos` (short name -> owner/name): what a #123 on the band links to.
33async function learnRepos($: EngineInterface, cwds: string[]): Promise<Links> {
34  const found: Record<string, string> = {}
35  for (const cwd of new Set(cwds)) {
36    if (!cwd || scanned.has(cwd)) continue
37    scanned.add(cwd)
38    try {
39      const ran = await $.process.run(['git', '-C', cwd, 'remote', '-v'])
40      const slug = ran.exitCode === 0 ? ran.stdout.match(/github\.com[:/]([^/\s]+\/[^\s]+?)(?:\.git)?\s/)?.[1] : undefined
41      if (slug) found[cwd] = slug
42    } catch {}
43  }
44  const extra: Record<string, string> = (await config($)).repos ?? {}
45  const held = await read($, links)
46  if (Object.keys(found).length === 0 && JSON.stringify(extra) === JSON.stringify(held.extra)) return held
47  let out = held
48  await update($, links, l => (out = { byCwd: { ...l.byCwd, ...found }, extra }))
49  return out
50}
51
52// Puts each shown worker's state into the band's atom. It makes no herdr calls: a running
53// worker shows the state its wait or the sweep last saw (lastStatus), and a failed sweep
54// leaves that as it was, so one error never shows a worker as gone.
55export async function refresh($: EngineInterface) {
56  const now = await $.clock.now()
57  await pingQuiet($, d => now - d.at > QUIET_MAX_MS)
58  const { byCwd } = await learnRepos($, [session.cwd, ...Object.values(await jobs($)).map(j => j.cwd)])
59  const asking = new Set((await openQuestions($)).map(d => d.from))
60  const shown = Object.values(await jobs($)).filter(
61    j => j.status === 'running' || asking.has(j.name) || (j.endedAt !== undefined && now - j.endedAt < LINGER_MS),
62  )
63  const rows: Row[] = []
64  for (const j of shown) {
65    let state = 'reported'
66    if (j.status === 'running') state = lastStatus.get(j.name) ?? 'working'
67    if (j.status === 'running' && j.wait && state !== 'blocked' && state !== 'gone') state = 'waiting'
68    rows.push({
69      name: j.name,
70      profile: j.profile,
71      state,
72      startedAt: j.startedAt,
73      endedAt: j.endedAt,
74      ...(j.status === 'running' && j.wait ? { note: `${elapsed(now - j.wait.since)} · ${j.wait.for}` } : {}),
75      ...(byCwd[j.cwd] ? { repo: byCwd[j.cwd] } : {}),
76    })
77  }
78  const held = await read($, board)
79  if (rows.length === 0 && held.rows.length === 0) return
80  const want = await target($)
81  const wrapUp = await wrappingUp($)
82  await update($, board, b => ({ ...b, now, rows, target: want, wrapUp }))
83}
84
hooks/commands.ts 186 lines
1import type { EngineInterface } from 'claude-code'
2import { allProfiles, BUILT_IN, config, customized, resetSettings, smallTasks, target, turnedOff, userConfigPath } from './config'
3import { orchestrate, toggleWrapUp } from './coordinator'
4import { openQuestions, pinnedItems } from './owner'
5import { cmd, session } from './session'
6import { closeSettings, openSettings, settingsOpen } from './settings'
7import { toggleSide } from './side'
8import { jobs } from './state'
9import { pruneMerged } from './worktrees'
10
11// The slash commands, all named <prefix><name> (see session.prefix).
12export async function registerCommands($: EngineInterface) {
13  const prefix = session.prefix
14  await $.command.register({ name: `${prefix}config`, description: 'Change the settings: target, built-in profiles, prefix and the rest.' })
15  for (const name of ['help', 'h'])
16    await $.command.register({
17      name: `${prefix}${name}`,
18      description: 'How herdr-agents works: commands, panel, profiles and settings.',
19      immediate: true,
20    })
21  await $.command.register({
22    name: `${prefix}side`,
23    description: 'Open or close the sidebar: the work by issue or PR, what needs you, and a box to answer it.',
24    immediate: true,
25  })
26  await $.command.register({ name: `${prefix}workers`, description: 'List the herdr workers of this session.' })
27  await $.command.register({ name: `${prefix}decisions`, description: 'List the questions and to-dos waiting for you.' })
28  await $.command.register({ name: `${prefix}prune`, description: 'Remove worktrees whose PR is merged.' })
29  await $.command.register({ name: `${prefix}update`, description: 'Pull the clone this mod was installed from and install it again.' })
30  await $.command.register({
31    name: `${prefix}orchestrate`,
32    description: 'Make this session the coordinator of worker agents for this folder, with a goal.',
33    argumentHint: '<goal, e.g. the issues labelled ready>',
34  })
35  await $.command.register({
36    name: `${prefix}wrap-up`,
37    description: `Start no new herdr workers; the running ones finish. ${cmd('wrap-up')} off ends it.`,
38    argumentHint: '[off]',
39    immediate: true,
40  })
41}
42
43// Commands are named at session start, so one hook answers them all; undefined for
44// any other command.
45export async function runCommand($: EngineInterface, command: string, args: string) {
46  const prefix = session.prefix
47  if (!command.startsWith(prefix)) return undefined
48  switch (command.slice(prefix.length)) {
49    case 'orchestrate':
50      return orchestrate($, args)
51    case 'wrap-up':
52      return { text: await toggleWrapUp($, !/^(off|resume)$/i.test(args.trim())) }
53    case 'side':
54      return { text: await toggleSide($) }
55    case 'prune':
56      return prune($)
57    case 'update':
58      return update($)
59    case 'decisions':
60      return listDecisions($)
61    case 'workers':
62      return listWorkers($)
63    case 'config':
64      if (await settingsOpen($)) {
65        await closeSettings($)
66        return { text: 'Settings closed.' }
67      }
68      await openSettings($)
69      return { text: `Settings are above the prompt: alt+d to move into them, Done or ${cmd('config')} again to close.` }
70    case 'help':
71    case 'h':
72      return help($)
73    default:
74      return undefined
75  }
76}
77
78async function prune($: EngineInterface) {
79  const done = await pruneMerged($)
80  return { text: done.length ? `Removed ${done.length} merged worktree(s):\n${done.join('\n')}` : 'No merged, clean worktrees to remove.' }
81}
82
83// `/hrd-update`: pulls the clone install.sh ran from (recorded in the mod's `clone` file) and runs its
84// install.sh again. Running sessions reload the mod by themselves, so the answer says only what changed.
85async function update($: EngineInterface) {
86  const clone = (await $.fs.read(`${$.plugin.root}/clone`).catch(() => '')).trim()
87  if (!clone) return { text: 'Nothing updated: no clone is recorded for herdr-agents. Run ./install.sh from your clone once.' }
88  const before = (await run($, ['git', '-C', clone, 'rev-parse', '--short', 'HEAD'])).stdout.trim()
89  const pull = await run($, ['git', '-C', clone, 'pull', '--ff-only'], 120_000)
90  if (pull.exitCode !== 0)
91    return { text: `Update stopped: git pull --ff-only failed in ${clone}, install.sh did not run: ${lastLines(pull.stderr)}` }
92  const after = (await run($, ['git', '-C', clone, 'rev-parse', '--short', 'HEAD'])).stdout.trim()
93  const install = await run($, [`${clone}/install.sh`], 120_000)
94  if (install.exitCode !== 0) return { text: `Update stopped: install.sh failed in ${clone}: ${lastLines(install.stderr)}` }
95  const version = JSON.parse(await $.fs.read(`${clone}/.claude-plugin/plugin.json`)).version
96  const reload = 'running sessions reload it by themselves.'
97  if (before === after) return { text: `herdr-agents is up to date at ${after}, version ${version}. install.sh ran again; ${reload}` }
98  return { text: `herdr-agents updated from ${before} to ${after}, version ${version}. Installed; ${reload}` }
99}
100
101// Runs a command. One that cannot start, or runs past its timeout, reads as failed, its error as stderr.
102async function run($: EngineInterface, argv: string[], timeoutMs = 30_000) {
103  return $.process.run(argv, { timeoutMs }).catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
104}
105
106// The last three lines of a command's stderr, on one line.
107function lastLines(stderr: string) {
108  return stderr.trim().split('\n').slice(-3).join(' / ') || 'no output on stderr'
109}
110
111async function listDecisions($: EngineInterface) {
112  const open = await openQuestions($)
113  const items = await pinnedItems($)
114  if (open.length === 0 && items.length === 0) return { text: 'Nothing waiting for you.' }
115  return {
116    text: [
117      ...items.map(t => `T${t.id}  ${t.kind === 'do' ? 'to do' : 'to know'}  (${t.from})  ${t.text}`),
118      ...open.map(d => `D${d.id}  (${d.from})  ${d.question}`),
119    ].join('\n\n'),
120  }
121}
122
123async function listWorkers($: EngineInterface) {
124  const list = Object.values(await jobs($))
125  if (list.length === 0) return { text: 'No herdr workers in this session.' }
126  return { text: list.map(j => `${j.name}  ${j.profile}  ${j.status}  ${j.cwd}`).join('\n') }
127}
128
129function commandRows(rows: [string, string][]) {
130  const width = Math.max(...rows.map(([name]) => name.length)) + 3
131  return rows.map(([name, what]) => `  ${name.padEnd(width)}${what}`)
132}
133
134// The guide the help command prints, with this folder's settings filled in.
135async function help($: EngineInterface) {
136  const c = await config($)
137  const all = await allProfiles($)
138  const off = await turnedOff($)
139  const small = await smallTasks($)
140  const { statePath, limit } = await resetSettings($)
141  const tags = (name: string) =>
142    [customized(name, all[name]) ? 'customized' : '', off.has(name) ? 'off' : '']
143      .filter(Boolean)
144      .map(tag => ` · ${tag}`)
145      .join('')
146  const profileRows = (title: string, names: string[]) =>
147    names.length ? [`  ${title}`, ...names.map(name => `    ${name.padEnd(12)} ${all[name]?.note ?? all[name]?.kind}${tags(name)}`)] : []
148  return {
149    text: [
150      'This session coordinates worker agents that run in herdr tabs; their reports come back here as prompts.',
151      '',
152      'Start',
153      ...commandRows([
154        [`${cmd('orchestrate')} <goal>`, `make this session the coordinator, e.g. ${cmd('orchestrate')} the issues labelled ready`],
155        [`${cmd('wrap-up')} [off]`, 'start no new workers and let the running ones finish; off resumes'],
156        [cmd('side'), 'the sidebar beside the chat: work by issue or PR, what needs you, an answer box'],
157        [cmd('decisions'), 'the questions and to-dos waiting for you'],
158        [cmd('workers'), "this session's workers"],
159        [cmd('prune'), 'remove worktrees whose PR is merged'],
160        [cmd('update'), 'pull the clone this mod was installed from, and install it again'],
161        [cmd('config'), 'change these settings, in the panel above the prompt'],
162        [`${cmd('help')}, ${cmd('h')}`, 'this guide'],
163      ]),
164      '',
165      'Panel (above the prompt)',
166      '  To-dos the coordinator pinned for you, then its questions, then the workers.',
167      '  Answer a question with 1–4 from an empty prompt, or alt+d (ctrl+x tab), arrows and Enter; Esc goes back.',
168      '  Details shows every worker; Wrap up / Keep going toggles the wrap-up.',
169      `  While ${cmd('side')} is open beside the chat, it shows all of this and the band steps aside.`,
170      '',
171      'Profiles (spawn takes one)',
172      ...profileRows('Built in', BUILT_IN),
173      ...profileRows(
174        'Yours',
175        Object.keys(all).filter(name => !BUILT_IN.includes(name)),
176      ),
177      `  Small tasks go to ${small ?? 'nobody (smallTasks)'}`,
178      '',
179      'Settings',
180      `  ${await userConfigPath($)}, with ${session.cwd}/.claude/herdr-agents.json over it`,
181      `  target ${await target($)} workers · state file ${statePath} · compact past ${limit} tokens · prefix ${session.prefix}${c.wrapUp === true ? ' · wrapUp on everywhere' : ''}`,
182      `  ${cmd('config')} changes them. Keys: target, resetAt, statePath, trustRoots, commandPrefix, profiles, disable, smallTasks, playbook, pruneMinutes, closeIdleMinutes, repos, wrapUp. See the README.`,
183    ].join('\n'),
184  }
185}
186
hooks/bridge.ts 60 lines
1import type { EngineInterface } from 'claude-code'
2import { BRIDGE_ACTIONS_KEPT, BRIDGE_DIR } from './defaults'
3import { boardAct, boardInputs, boardSnapshot, type BoardAction } from './side'
4
5// The board for apps outside Claude Code, such as contr (a native herdr client):
6// ~/.herdr-agents/board/<session id>.json holds what the sidebar shows, rewritten when it
7// changes (docs/board-schema.json), and each file the app drops in <session id>.actions/
8// is one press of a sidebar button, done on the next pass and removed. What came of the
9// last few is in the snapshot's `actions`.
10
11// What the snapshot last written was built from, and what came of the last actions.
12let written = ''
13let results: { id: string; ok: boolean; message: string }[] = []
14
15async function paths($: EngineInterface) {
16  const dir = `${(await $.env.get('HOME')) ?? ''}/${BRIDGE_DIR}`
17  const id = await $.session.id()
18  return { dir, file: `${dir}/${id}.json`, inbox: `${dir}/${id}.actions` }
19}
20
21// Runs with the band's refresh: the actions waiting first, so the snapshot shows what they did.
22export async function publishBoard($: EngineInterface) {
23  const { file, inbox } = await paths($)
24  await takeActions($, inbox)
25  const inputs = (await boardInputs($)) + JSON.stringify(results)
26  if (inputs === written) return
27  const snap = { ...(await boardSnapshot($)), actions: results }
28  await $.process.run(['mkdir', '-p', inbox])
29  // Written beside it and moved over it, so a reader never sees half a file.
30  await $.fs.write(`${file}.tmp`, JSON.stringify(snap, null, 2))
31  const moved = await $.process.run(['mv', '-f', `${file}.tmp`, file])
32  if (moved.exitCode === 0) written = inputs
33}
34
35async function takeActions($: EngineInterface, inbox: string) {
36  if (!(await $.fs.exists(inbox))) return
37  const files = (await $.fs.list(inbox))
38    .filter(f => f.kind === 'file' && f.name.endsWith('.json'))
39    .map(f => f.name)
40    .sort()
41  for (const name of files) {
42    const path = `${inbox}/${name}`
43    let result: (typeof results)[number]
44    try {
45      const a = JSON.parse(await $.fs.read(path)) as BoardAction
46      result = { id: name, ok: true, message: await boardAct($, a) }
47    } catch (err) {
48      result = { id: name, ok: false, message: String(err instanceof Error ? err.message : err).slice(0, 200) }
49    }
50    await $.process.run(['rm', '-f', path])
51    results = [...results, result].slice(-BRIDGE_ACTIONS_KEPT)
52  }
53}
54
55// The session is ending: its board and inbox go.
56export async function removeBoard($: EngineInterface) {
57  const { file, inbox } = await paths($)
58  await $.process.run(['rm', '-rf', file, `${file}.tmp`, inbox])
59}
60
hooks/config.ts 140 lines
1import type { EngineInterface } from 'claude-code'
2import type { Profile, Settings } from '../types'
3import { RESET_AT, STATE_OWNER, TARGET, WRAP_UP } from './defaults'
4import { session } from './session'
5
6// Claude workers run in their reviewed auto mode (each approval goes to the
7// CLI's own automatic reviewer). A worker waiting at a prompt is reported as
8// blocked. Profiles in herdr-agents.json add to these or replace them by name,
9// which is where the person adds other CLIs (codex, opencode) or approval modes.
10export const PROFILES: Record<string, Profile> = {
11  // The subagents in this repo's agents/ folder, which Claude Code names after the plugin.
12  // `claude --agent` ignores a subagent's `effort:` line, so each profile passes --effort.
13  planner: {
14    kind: 'claude',
15    args: ['--agent', 'herdr-agents:planner', '--effort', 'high', '--permission-mode', 'auto'],
16    note: 'Claude planner agent (Opus high)',
17  },
18  builder: {
19    kind: 'claude',
20    args: ['--agent', 'herdr-agents:builder', '--effort', 'xhigh', '--permission-mode', 'auto'],
21    note: 'Claude builder agent (Haiku xhigh)',
22  },
23  reviewer: {
24    kind: 'claude',
25    args: ['--agent', 'herdr-agents:reviewer', '--effort', 'high', '--permission-mode', 'auto'],
26    note: 'Claude reviewer agent (Opus high)',
27  },
28  // No subagent: the full toolset, for root-cause hunts and fixes that failed twice.
29  debugger: {
30    kind: 'claude',
31    args: ['--model', 'opus', '--effort', 'medium', '--permission-mode', 'auto'],
32    note: 'Claude Opus medium, full tools (debugging, tricky fixes)',
33  },
34}
35
36// The built-in profiles are the playbook's steps: a profile of the same name in
37// herdr-agents.json changes its model or flags, and `disable` turns off any but the
38// builder, which every issue needs. `smallTasks` names a profile for small,
39// well-defined tasks (none by default).
40export const BUILT_IN = Object.keys(PROFILES)
41
42export const OPTIONAL = ['planner', 'reviewer', 'debugger']
43
44export async function readJson($: EngineInterface, path: string): Promise<Settings> {
45  if (!(await $.fs.exists(path))) return {}
46  try {
47    return JSON.parse(await $.fs.read(path))
48  } catch {
49    return {}
50  }
51}
52
53export async function userConfigPath($: EngineInterface): Promise<string> {
54  return `${(await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`}/herdr-agents.json`
55}
56
57// ~/.claude/herdr-agents.json (or $CLAUDE_CONFIG_DIR's), with the repo's own
58// <session cwd>/.claude/herdr-agents.json over it (its profiles merged by name),
59// so each repo can set its target, state file, profiles and the rest.
60export async function config($: EngineInterface): Promise<Settings> {
61  const user = await readJson($, await userConfigPath($))
62  const repo = session.cwd ? await readJson($, `${session.cwd}/.claude/herdr-agents.json`) : {}
63  return { ...user, ...repo, profiles: { ...user.profiles, ...repo.profiles } }
64}
65
66// Every profile, the turned-off built-ins too.
67export async function allProfiles($: EngineInterface): Promise<Record<string, Profile>> {
68  return { ...PROFILES, ...(await config($)).profiles }
69}
70
71export async function turnedOff($: EngineInterface): Promise<Set<string>> {
72  const named = (await config($)).disable
73  return new Set((Array.isArray(named) ? named : []).filter((name: string) => OPTIONAL.includes(name)))
74}
75
76// The profiles spawn takes.
77export async function profiles($: EngineInterface): Promise<Record<string, Profile>> {
78  const all = await allProfiles($)
79  for (const name of await turnedOff($)) delete all[name]
80  return all
81}
82
83export async function smallTasks($: EngineInterface): Promise<string | undefined> {
84  const name = (await config($)).smallTasks
85  return typeof name === 'string' && (await profiles($))[name] ? name : undefined
86}
87
88// A built-in profile whose settings differ from the one shipped.
89export function customized(name: string, p: Profile | undefined) {
90  const shipped = PROFILES[name]
91  return !!shipped && !!p && JSON.stringify(p) !== JSON.stringify(shipped)
92}
93
94// Workers that read a worktree without building in it; they never block a builder spawn.
95export function reviewsOnly(profile: string) {
96  return profile === 'reviewer' || profile === 'planner'
97}
98
99// Where the coordinator keeps its state, and the context size past which it is reset
100// (see maybeReset). The state file belongs to the session that last wrote it (recorded
101// under STATE_OWNER in $.store); any other session saves to a handoff file of its own.
102export async function resetSettings($: EngineInterface) {
103  const c = await config($)
104  const named = (typeof c.statePath === 'string' ? c.statePath : 'COORDINATOR-STATE.md').replace(
105    /^~(?=\/)/,
106    (await $.env.get('HOME')) ?? '~',
107  )
108  const shared: string = named.startsWith('/') ? named : `${session.cwd}/${named}`
109  const id: string = await $.session.id()
110  const owner = (await $.store.get(STATE_OWNER)) as { path: string; session: string } | undefined
111  const mine = !owner || owner.path !== shared || owner.session === id
112  return {
113    limit: typeof c.resetAt === 'number' ? c.resetAt : RESET_AT,
114    coordinator: mine,
115    statePath: mine ? shared : `${session.cwd}/.herdr-agents/handoff-${id.slice(0, 8)}.md`,
116    shared,
117  }
118}
119
120// Records this session as the owner of the shared state file once it writes it.
121export async function claimState($: EngineInterface, file: unknown) {
122  const { shared } = await resetSettings($)
123  if (file === shared) await $.store.set(STATE_OWNER, { path: shared, session: await $.session.id() })
124}
125
126export async function target($: EngineInterface): Promise<number> {
127  const c = await config($)
128  return typeof c.target === 'number' ? c.target : TARGET
129}
130
131// The owner's wrap-up switch: while it is on, no new worker starts (a reviewer excepted:
132// it finishes an open PR) and reports stop asking for more, while the running workers
133// finish. The wrap-up command turns it on for the coordinators of this session's folder
134// (kept in $.store, so every session there sees it); `wrapUp: true` in the user's
135// settings turns it on everywhere. Wrap-up off clears both.
136export async function wrappingUp($: EngineInterface): Promise<boolean> {
137  const folders = ((await $.store.get(WRAP_UP)) ?? {}) as Record<string, boolean>
138  return folders[session.cwd] === true || (await config($)).wrapUp === true
139}
140
hooks/coordinator.ts 179 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3import type { Reset } from '../types'
4import { refresh } from './board'
5import { config, resetSettings, smallTasks, target, turnedOff, userConfigPath } from './config'
6import { SAVE_WAIT_MS, WRAP_UP } from './defaults'
7import { pinned } from './owner'
8import { cmd, session } from './session'
9import { jobs } from './state'
10
11// The coordinator's side: the playbook it follows, the worker target and wrap-up,
12// and keeping its context small.
13
14// The context reset under way (see maybeReset), and whether /hrd-orchestrate ran here.
15const resetAtom = atom({ plugin: 'herdr-agents', key: 'reset' } as const, null as Reset | null)
16const orchestrating = atom({ plugin: 'herdr-agents', key: 'orchestrating' } as const, false)
17
18// What the coordinator does after two failed fix rounds.
19export function afterFailedRounds(off: Set<string>) {
20  return off.has('debugger') ? 'ask the owner with ask_owner' : 'spawn `debugger`'
21}
22
23export async function setWrapUp($: EngineInterface, on: boolean) {
24  const folders = { ...((await $.store.get(WRAP_UP)) ?? {}) } as Record<string, boolean>
25  if (on) folders[session.cwd] = true
26  else delete folders[session.cwd]
27  await $.store.set(WRAP_UP, folders)
28  const path = await userConfigPath($)
29  // Parsed here, not through config(): a broken file throws instead of being overwritten.
30  const c = !on && (await $.fs.exists(path)) ? JSON.parse(await $.fs.read(path)) : {}
31  if (c.wrapUp !== undefined) {
32    delete c.wrapUp
33    await $.fs.write(path, `${JSON.stringify(c, null, 2)}\n`)
34  }
35  void refresh($)
36}
37
38// What the orchestrate command hands the session, and again after each compaction.
39// `playbook` in herdr-agents.json (a line or a list of lines) adds the person's own.
40export async function playbook($: EngineInterface, statePath: string) {
41  const want = await target($)
42  const off = await turnedOff($)
43  const extra = (await config($)).playbook ?? []
44  const small = await smallTasks($)
45  const steps = [
46    off.has('planner')
47      ? 'spawn `builder` with the issue; it plans before it builds'
48      : "spawn `planner` for anything unplanned or non-trivial (its plan lands in PLAN.md), then `builder` with the plan's path",
49    off.has('reviewer')
50      ? "then review the builder's PR yourself (diff and checks) before it merges"
51      : "then `reviewer` on the builder's PR",
52  ]
53  return [
54    'You coordinate worker agents for the repo(s) in this folder: you plan, start and check them and land their PRs; you write little code yourself.',
55    '- Rules: read AGENTS.md or CLAUDE.md first. They name the base branch, the checks, the PR format, who merges and what needs the owner; workers read them too.',
56    `- State: keep ${statePath} current, updated after every report: Running (worker, profile, worktree, issue or PR, what it does), Ready (planned items that can start now, at least 3), Waiting (CI, review, merge), Decisions, Next. A fresh context carries on from it alone.`,
57    "- Reports: a <herdr-agent> report starts with the worker's summary. Read its full report file only when the summary is not enough; give a fix builder that path instead of copying the report.",
58    '- Worktrees: each worker gets its own git worktree off the base branch, next to the repo (`git worktree add -b <branch> ../<repo>-<slug> origin/<base>`); never two writers in one.',
59    `- Each issue: ${steps.join(', ')}. Send blockers to a fresh builder: stop the one that wrote the PR, then spawn \`builder\` in the same worktree with the PR and the blockers; after two failed fix rounds, ${afterFailedRounds(off)}.${small ? ` Small, well-defined tasks may go to \`${small}\`.` : ''}`,
60    `- Throughput: keep ${want} workers busy; each report says how many work. Split big work into small PRs and run independent slices at once.`,
61    '- Waiting: never poll or sleep; reports, WAIT.json checks and decisions arrive by themselves.',
62    '- The owner: ask only what is theirs (product, design or rule choices, approvals) with ask_owner, a few at a time; settle the rest yourself with decided. Merge only as the repo rules allow.',
63    '- Things the owner must do (test a build, merge a PR that waits for them) or should know: pin each with pin_for_owner, never only in a message, and unpin it with unpin_for_owner once it is done.',
64    `- When the owner runs ${cmd('wrap-up')}: start nothing new, let the running workers finish, then update the state file and stop.`,
65    ...(Array.isArray(extra) ? extra : [extra]).map((line: string) => `- ${line}`),
66  ].join('\n')
67}
68
69// Turns wrap-up on or off and, in a session that has started workers, tells its model.
70export async function toggleWrapUp($: EngineInterface, on: boolean) {
71  await setWrapUp($, on)
72  const live = Object.values(await jobs($)).filter(j => j.status === 'running')
73  if (Object.keys(await jobs($)).length > 0) {
74    void $.prompt.submit({
75      text: on
76        ? `<herdr-context>The owner is wrapping up: start no new workers (a reviewer for an open PR is allowed). Let the ${live.length} running finish, send them fixes with message and merge what is ready. When the last one has reported, update the state file and stop.</herdr-context>`
77        : `<herdr-context>The owner ended the wrap-up: keep ${await target($)} workers busy again from the Ready list.</herdr-context>`,
78    })
79  }
80  const names = live.map(j => j.name).join(', ')
81  return on
82    ? `Wrapping up: no new herdr workers start. ${live.length ? `${live.length} still running finish: ${names}.` : 'None are running.'} ${cmd('wrap-up')} off ends it.`
83    : 'Wrap-up off: workers start again.'
84}
85
86// The live workers, for the compaction summary to keep.
87export async function roster($: EngineInterface) {
88  return Object.values(await jobs($))
89    .filter(j => j.status === 'running')
90    .map(j => `- ${j.name} (${j.profile}) in ${j.cwd}, job folder ${j.dir}`)
91    .join('\n')
92}
93
94let resetting = false
95
96// Keeps a coordinator's context small. Past the limit it asks the model to
97// save its state to the state file; once that file is newer than the ask (or
98// SAVE_WAIT_MS passed), it compacts with a short summary pointing at the file.
99// Only a session that has started workers is reset.
100export async function maybeReset($: EngineInterface, tokens: number) {
101  if (resetting || Object.keys(await jobs($)).length === 0) return
102  const { limit, statePath, coordinator, shared } = await resetSettings($)
103  const reset = await read($, resetAtom)
104  if (tokens < limit) {
105    if (reset) await update($, resetAtom, () => null)
106    return
107  }
108  const now = await $.clock.now()
109  if (!reset) {
110    await update($, resetAtom, (): Reset => ({ stage: 'saving', at: now }))
111    await $.prompt.submit({
112      text: [
113        `<herdr-context tokens="${Math.round(tokens / 1000)}k" limit="${Math.round(limit / 1000)}k">`,
114        ...(coordinator
115          ? [`This session's context is past the limit. Update ${statePath} now so a fresh context can carry on from it alone:`]
116          : [
117              `This session's context is past the limit. It is not the coordinator: leave ${shared} alone (another session owns it)`,
118              `and write this session's own handoff to ${statePath} so a fresh context can carry on from it alone:`,
119            ]),
120        'every running worker (name, profile, worktree, issue or PR, what it is doing), open PRs and their state,',
121        "what waits on review, CI or merge, the owner's decisions and standing instructions from this session,",
122        'open questions for the owner, ids already claimed (migration numbers and the like), and the next steps.',
123        'Replace stale content and keep it under about 150 lines. Then answer in one line.',
124        'The mod compacts the conversation right after.',
125        '</herdr-context>',
126      ].join('\n'),
127    })
128    return
129  }
130  let saved = false
131  try {
132    saved = (await $.fs.exists(statePath)) && (await $.fs.stat(statePath)).mtimeMs > reset.at
133  } catch {}
134  if (!saved && now - reset.at < SAVE_WAIT_MS) return
135
136  resetting = true
137  try {
138    const live = await roster($)
139    const items = await pinned($)
140    const result = await $.session.compact({
141      instructions: [
142        `Keep this summary short, under 1,500 words. This session's full state is in ${statePath}:`,
143        'say to read it first and trust it over this summary.',
144        "Keep only what that file may lack: the owner's latest messages and anything that happened after it was saved.",
145        live ? `Workers still running in herdr tabs:\n${live}` : 'No herdr workers are running.',
146        ...(items ? [`Still pinned for the owner (unpin_for_owner once done):\n${items}`] : []),
147      ].join('\n'),
148    })
149    if ('skip' in result && result.skip) return
150    await update($, resetAtom, () => null)
151    $.ui.toast('herdr-agents: context compacted')
152    const coordinating = await read($, orchestrating)
153    await $.prompt.submit({
154      text: `<herdr-context>The conversation was compacted to keep the context small. Read ${statePath} before acting on the next report.${coordinating ? `\n${await playbook($, statePath)}` : ''}</herdr-context>`,
155    })
156  } catch {
157    // A turn is running; try again after the next one.
158  } finally {
159    resetting = false
160  }
161}
162
163export async function orchestrate($: EngineInterface, args: string) {
164  const goal = args.trim()
165  const { statePath } = await resetSettings($)
166  await update($, orchestrating, () => true)
167  void $.prompt.submit({
168    text: [
169      '<herdr-context>You are now the coordinator of this folder.',
170      await playbook($, statePath),
171      goal
172        ? `Goal: ${goal}`
173        : 'Goal: none given. Read the state file and the open issues, then propose the first ones to the owner with ask_owner.',
174      '</herdr-context>',
175    ].join('\n'),
176  })
177  return { text: `Coordinating${goal ? `: ${goal}` : ''}. State file: ${statePath}. ${cmd('wrap-up')} stops new work.` }
178}
179
hooks/defaults.ts 85 lines
1// Defaults and tuning values in one place. The first group are the defaults of the
2// settings in herdr-agents.json (see config.ts); the rest are the mod's own.
3
4// Workers the coordinator keeps busy (`target`).
5export const TARGET = 4
6// Context tokens past which the coordinator saves its state and is compacted (`resetAt`).
7export const RESET_AT = 200_000
8// What every slash command starts with (`commandPrefix`).
9export const PREFIX = 'hrd-'
10// How often merged worktrees are pruned (`pruneMinutes`); 0 turns it off.
11export const PRUNE_MINUTES = 30
12// How long a reported worker's tab may sit idle before it is closed (`closeIdleMinutes`); 0 keeps it.
13export const CLOSE_IDLE_MINUTES = 30
14
15// A worker's name: also its herdr tab's label.
16export const NAME = /^[a-z0-9][a-z0-9-]{0,39}$/
17// A report without a header (see report.ts) longer than this goes to the coordinator as its first line and its file's path.
18export const INLINE_LINES = 6
19export const INLINE_CHARS = 700
20// A report's header (see report.ts): the lines it may start within, and the summary's size.
21export const HEADER_SCAN_LINES = 12
22export const SUMMARY_LINES = 3
23export const SUMMARY_LINE_CHARS = 240
24// A follow-up to a worker up to this long is typed into its prompt as well as saved to a file.
25export const MESSAGE_CHARS = 4000
26// How long a worker may sit with an unchanged screen, not working and without
27// RESULT.md, before it is reported as stopped.
28export const QUIET_MS = 5 * 60_000
29// The same for a worker whose status lines show background work of its own (a shell,
30// a monitor, a subagent): a dev server it left running must not hide it for good.
31export const QUIET_BUSY_MS = 60 * 60_000
32// How often a prompt is sent or submitted again when herdr sees no work start after it.
33export const PROMPT_TRIES = 3
34// How often the sweep reads herdr for every running worker at once (one `herdr agent list`).
35export const SWEEP_MS = 60_000
36// How many sweeps in a row must miss a worker's tab before it is reported gone: one failed
37// or late list does not end a job.
38export const GONE_MISSES = 2
39// How long a prompt that never arrived waits before its worker is reported blocked.
40export const LOST_REPORT_MS = 10_000
41// Prompts for the coordinator (reports, owner answers) wait this long after the last one,
42// and after its turn ends, so the ones that arrive together go out as one prompt (see outbox.ts).
43export const BATCH_MS = 3000
44// How long a reported worker stays on the band.
45export const LINGER_MS = 60_000
46
47// How long a settled decision stays on the band.
48export const SETTLED_MS = 10 * 60_000
49// How long a worker's question waits for the coordinator before the person is pinged anyway.
50export const QUIET_MAX_MS = 3 * 60_000
51
52// If the coordinator hasn't saved its state this long after being asked, compact anyway.
53export const SAVE_WAIT_MS = 10 * 60_000
54
55// The band: to-dos shown (the decisions command lists the rest), worker lines in the
56// compact view (Details shows them all), and the narrowest worker column; a band at
57// least twice as wide shows two workers per line.
58export const TODO_ROWS = 6
59export const WORKER_LINES = 2
60export const WORKER_CELL = 56
61
62// The sidebar (/hrd-side): the width it asks for when docked, its fixed rows (work
63// table, needs-you list, the answer box), how long a finished task still shows when it
64// first appears, and how often it reads the GitHub state of the PRs it lists.
65export const SIDE_COLUMNS = 64
66export const SIDE_WORK_ROWS = 8
67export const SIDE_NEED_ROWS = 6
68export const SIDE_BOX_ROWS = 8
69export const SIDE_RECENT_MS = 3 * 60 * 60_000
70export const PR_POLL_MS = 2 * 60_000
71
72// Keys in $.store, which every session on the machine shares.
73export const STATE_OWNER = 'stateOwner'
74export const WRAP_UP = 'wrapUp'
75// A session's jobs, under this prefix and its id (see state.ts), kept this long after their last change.
76export const JOBS_PREFIX = 'jobs:'
77export const JOBS_KEEP_MS = 3 * 24 * 60 * 60_000
78// The outbox's copy in $.store (see outbox.ts), under this prefix and the session's id.
79export const OUTBOX_PREFIX = 'outbox:'
80
81// The board for apps outside Claude Code (see bridge.ts), under the home folder, and how
82// many of their last actions the snapshot reports.
83export const BRIDGE_DIR = '.herdr-agents/board'
84export const BRIDGE_ACTIONS_KEPT = 10
85
hooks/outbox.ts 121 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3import type { OutboxItem } from '../types'
4import { BATCH_MS, JOBS_KEEP_MS, OUTBOX_PREFIX } from './defaults'
5import { belowTarget } from './target'
6
7// The prompts for the coordinator that come close together go out as one: reports (each a
8// <herdr-agent> element), the owner's answers and the to-dos they ticked. An item waits until
9// BATCH_MS have passed since the last one and since the coordinator's turn ended, and nothing
10// is sent while a turn runs: a prompt sent then would only queue behind it. The outbox is also
11// kept in $.store, so a reload finds what it held (see restoreOutbox).
12
13const outbox = atom({ plugin: 'herdr-agents', key: 'outbox' } as const, [] as OutboxItem[])
14
15// Whether a main-loop turn runs, when an item last came in or a turn last ended, and whether
16// a loop is waiting to flush (only one at a time). A reload resets busy to false, so a batch can
17// go out during a turn that was running before it: that is acceptable, as the prompt only queues
18// behind that turn and nothing is lost.
19let busy = false
20let lastAt = 0
21let flushing = false
22
23// The main loop's turn started or ended (register.tsx).
24export function turnStarted() {
25  busy = true
26}
27export async function turnEnded($: EngineInterface) {
28  busy = false
29  lastAt = await $.clock.now()
30  void kick($)
31}
32
33// Queues a prompt for the coordinator. A batch with a report ends with the worker count (see flush).
34export async function post($: EngineInterface, text: string, report = false) {
35  const item: OutboxItem = report ? { text, report: true } : { text }
36  await update($, outbox, list => [...list, item])
37  lastAt = await $.clock.now()
38  await save($)
39  void kick($)
40}
41
42// The sweep's tick (register.tsx): a batch that a refused prompt kept goes out without a new post or turn's end.
43export function retry($: EngineInterface) {
44  void kick($)
45}
46
47// Waits for the quiet and flushes, while items are held. A post or a turn's end that finds the
48// loop running needs nothing more: the loop looks again after its wait.
49async function kick($: EngineInterface) {
50  if (flushing) return
51  flushing = true
52  try {
53    while ((await read($, outbox)).length > 0) {
54      await $.clock.sleep(BATCH_MS)
55      // A turn is running: its end starts the loop again.
56      if (busy) return
57      if ((await $.clock.now()) - lastAt < BATCH_MS) continue
58      // A prompt that could not be sent keeps its items; the next post, turn's end or sweep tries again.
59      if (!(await flush($))) return
60    }
61  } catch {
62    // Background work: a failed step ends the loop, and the next post or turn's end starts it again.
63  } finally {
64    flushing = false
65  }
66}
67
68// Takes the held items and sends them as one prompt. False when the send failed: the items go back.
69// With no items there is nothing to send: a loop that wakes after a reload emptied the state sends
70// no empty prompt, and the copy in $.store is left for the restore.
71async function flush($: EngineInterface): Promise<boolean> {
72  let items: OutboxItem[] = []
73  await update($, outbox, list => {
74    items = list
75    return []
76  })
77  if (items.length === 0) return true
78  try {
79    let text = items.map(i => i.text).join('\n\n')
80    if (items.some(i => i.report)) text += await belowTarget($)
81    await save($)
82    await $.prompt.submit({ text: text.trim() })
83    return true
84  } catch (err) {
85    await update($, outbox, list => [...items, ...list])
86    await save($)
87    $.ui.toast(`herdr-agents: a prompt for the coordinator was not sent (${err}); it goes with the next one`)
88    return false
89  }
90}
91
92// The held items in $.store under the session's key, or none once they are out.
93async function save($: EngineInterface) {
94  const items = await read($, outbox)
95  const key = `${OUTBOX_PREFIX}${await $.session.id()}`
96  if (items.length > 0) await $.store.set(key, { at: lastAt, items })
97  else await $.store.delete(key)
98}
99
100// session.start: a reload may have left items held. $.state keeps the outbox only sometimes (as
101// with the jobs), so its copy in $.store is taken when the state is empty, and the batch goes out
102// at once. Other sessions' copies older than the jobs' are removed, as the jobs' copies are.
103export async function restoreOutbox($: EngineInterface) {
104  const now = await $.clock.now()
105  const mine = `${OUTBOX_PREFIX}${await $.session.id()}`
106  for (const key of await $.store.keys()) {
107    if (!key.startsWith(OUTBOX_PREFIX) || key === mine) continue
108    const { at = 0 } = ((await $.store.get(key)) ?? {}) as { at?: number }
109    if (now - at > JOBS_KEEP_MS) await $.store.delete(key)
110  }
111  if ((await read($, outbox)).length === 0) {
112    const kept = ((await $.store.get(mine)) ?? {}) as { at?: number; items?: OutboxItem[] }
113    const items = kept.items ?? []
114    if (items.length > 0) {
115      lastAt = kept.at ?? 0
116      await update($, outbox, () => items)
117    }
118  }
119  void kick($)
120}
121
hooks/owner.ts 219 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3import type { Decision, Settled, Todo } from '../types'
4import { SETTLED_MS } from './defaults'
5import { notify } from './herdr'
6import { post } from './outbox'
7
8// What waits on the person: questions (ask_owner, workers' DECISION: lines) and
9// to-dos (pin_for_owner), pinned on the band until answered or ticked off.
10
11const decisions = atom({ plugin: 'herdr-agents', key: 'decisions' } as const, [] as Decision[])
12const settled = atom({ plugin: 'herdr-agents', key: 'settled' } as const, [] as Settled[])
13const lastId = atom({ plugin: 'herdr-agents', key: 'lastId' } as const, 0)
14const todos = atom({ plugin: 'herdr-agents', key: 'todos' } as const, [] as Todo[])
15const lastTodo = atom({ plugin: 'herdr-agents', key: 'lastTodo' } as const, 0)
16
17// A decision's name in a list: its title, else the question's first words.
18export function decisionTitle(d: Decision) {
19  return (
20    d.title ??
21    d.question
22      .replace(/^\s*(TEST \(ignore\)\s*:?\s*)?(Q\d+\s*:\s*)?([#\w-]*\d+\s*:?\s*)?/i, '')
23      .replace(/^[^\p{L}\p{N}]+/u, '')
24      .split(/\s+/)
25      .slice(0, 4)
26      .join(' ')
27      .replace(/[?:,.;]+$/, '')
28  )
29}
30
31export function openQuestions($: EngineInterface) {
32  return read($, decisions)
33}
34export function answered($: EngineInterface) {
35  return read($, settled)
36}
37export function pinnedItems($: EngineInterface) {
38  return read($, todos)
39}
40
41// Pins a question for the person on the band and notifies them. A `quiet` one
42// (a worker's DECISION: line) waits for the coordinator to read it first: see
43// pingQuiet. Ids never repeat within a session, so a stale id cannot close a
44// newer question.
45export async function ask(
46  $: EngineInterface,
47  from: string,
48  question: string,
49  options?: string[],
50  recommended?: number,
51  title?: string,
52  quiet = false,
53) {
54  const at = await $.clock.now()
55  const used = [...(await read($, decisions)), ...(await read($, settled))].map(d => d.id)
56  let id = 0
57  await update($, lastId, n => (id = Math.max(n, 0, ...used) + 1))
58  await update($, decisions, list => [
59    ...list,
60    {
61      id,
62      from,
63      question,
64      at,
65      ...(title ? { title } : {}),
66      ...(options?.length ? { options, recommended } : {}),
67      ...(quiet ? { quiet } : {}),
68    },
69  ])
70  if (!quiet) await notify($, `Decision D${id} needed (${from})`, question)
71  return id
72}
73
74// Pings the person for the quiet questions `pick` selects, once.
75export async function pingQuiet($: EngineInterface, pick: (d: Decision) => boolean) {
76  let due: Decision[] = []
77  await update($, decisions, list => {
78    due = list.filter(d => d.quiet && pick(d))
79    return due.length ? list.map(d => (due.includes(d) ? { ...d, quiet: false } : d)) : list
80  })
81  if (due.length === 1) await notify($, `Decision D${due[0]!.id} needed (${due[0]!.from})`, due[0]!.question)
82  else if (due.length > 1)
83    await notify($, `${due.length} decisions need you`, due.map(d => `D${d.id} ${d.title ?? d.question}`).join(' · '))
84}
85
86// The person picked an answer on the band: unpin it and hand it to the model.
87export async function choose($: EngineInterface, d: Decision, answer: string) {
88  await update($, decisions, list => list.filter(x => x.id !== d.id))
89  $.ui.toast(`D${d.id}: ${answer}`)
90  await post(
91    $,
92    [
93      `<herdr-decision id="D${d.id}" from="${d.from}">`,
94      `Question: ${d.question}`,
95      `The owner chose: ${answer}`,
96      'It is already unpinned. Act on it now (tell the worker with message, record it in the state file).',
97      '</herdr-decision>',
98    ].join('\n'),
99  )
100}
101
102// Pins one line for the person at the top of the band. The same text already
103// pinned keeps its id. Ids never repeat within a session.
104export async function pin($: EngineInterface, text: string, kind: Todo['kind'], from: string) {
105  const same = (await read($, todos)).find(t => t.text === text)
106  if (same) return { id: same.id, isNew: false }
107  const at = await $.clock.now()
108  let id = 0
109  await update($, lastTodo, n => (id = n + 1))
110  await update($, todos, list => [...list, { id, kind, text, from, at }])
111  if (kind === 'do') await notify($, `For you: T${id}`, text, false)
112  else $.ui.toast(`T${id}: ${text}`)
113  return { id, isNew: true }
114}
115
116// The person ticked an item off on the band: unpin it and, for a `do` item,
117// tell the model, which may be waiting on it (a merge, a test).
118export async function tick($: EngineInterface, t: Todo) {
119  await update($, todos, list => list.filter(x => x.id !== t.id))
120  if (t.kind !== 'do') return
121  await post(
122    $,
123    [
124      `<herdr-owner-done id="T${t.id}" from="${t.from.replace(/"/g, "'")}">`,
125      `The owner ticked off: ${t.text}`,
126      'It is already unpinned. Carry on from it (check it, tell the worker with message, record it in the state file).',
127      '</herdr-owner-done>',
128    ].join('\n'),
129  )
130}
131
132// The items pinned for the person, for the compaction summary to keep.
133export async function pinned($: EngineInterface) {
134  return (await read($, todos)).map(t => `- T${t.id} (${t.kind}, ${t.from}): ${t.text}`).join('\n')
135}
136
137// The main-loop turn reading worker reports, and the workers whose reports it reads:
138// their DECISION: questions ping the person once that turn ends, unless it settled them.
139let reading: { turnId: string; names: Set<string> } | undefined
140
141export function noteReports(turnId: string, text: string) {
142  const names = new Set([...text.matchAll(/<herdr-agent name="([^"]+)"/g)].map(m => m[1]!))
143  reading = names.size ? { turnId, names } : undefined
144}
145
146export async function pingAfterReports($: EngineInterface, turnId: string) {
147  if (reading?.turnId !== turnId) return
148  const { names } = reading
149  reading = undefined
150  await pingQuiet($, d => names.has(d.from))
151}
152
153export type AskArgs = { question: string; title?: string; options?: string[]; recommended?: number; from?: string }
154
155export async function askOwner($: EngineInterface, e: AskArgs) {
156  const id = await ask($, e.from ?? 'coordinator', e.question, e.options, e.recommended, e.title)
157  return { result: `Pinned as D${id} and notified the person. Call decided with "D${id}" once they answer.` }
158}
159
160export type DecidedArgs = { ids: string[]; answer: string; by: string }
161
162export async function settle($: EngineInterface, e: DecidedArgs) {
163  const ids = new Set((e.ids as string[]).map(s => Number(String(s).replace(/^D/i, ''))))
164  const by: Settled['by'] = e.by === 'owner' ? 'owner' : 'coordinator'
165  const answer = String(e.answer ?? '').trim() || 'closed'
166  const at = await $.clock.now()
167  let gone: Decision[] = []
168  let left = 0
169  await update($, decisions, list => {
170    gone = list.filter(d => ids.has(d.id))
171    const kept = list.filter(d => !ids.has(d.id))
172    left = kept.length
173    return kept
174  })
175  if (gone.length) {
176    await update($, settled, list => [
177      ...list.filter(x => at - x.at < SETTLED_MS),
178      ...gone.map(d => ({ id: d.id, from: d.from, question: d.question, ...(d.title ? { title: d.title } : {}), answer, by, at })),
179    ])
180    const names = gone.map(d => `D${d.id}${d.title ? ` ${d.title}` : ''}`).join(', ')
181    // A question the person was already pinged for gets a silent notification;
182    // a quiet one they never heard of only shows on the band.
183    if (by === 'coordinator' && gone.some(d => !d.quiet)) await notify($, `${names} settled by coordinator`, answer, false)
184    else $.ui.toast(`${names} settled by ${by}: ${answer}`)
185  }
186  const missing = [...ids].filter(id => !gone.some(d => d.id === id)).map(id => `D${id}`)
187  return {
188    result: `Unpinned ${gone.length}.${missing.length ? ` Not pinned (already answered or wrong id): ${missing.join(', ')}.` : ''} ${left} question(s) still wait for the person.`,
189  }
190}
191
192export type PinArgs = { text: string; kind?: string; from?: string }
193
194export async function pinForOwner($: EngineInterface, e: PinArgs) {
195  const text = String(e.text ?? '')
196    .replace(/\s+/g, ' ')
197    .trim()
198  if (!text) return { deny: 'text is empty' }
199  const from = String(e.from ?? '').trim() || 'coordinator'
200  const { id, isNew } = await pin($, text, e.kind === 'know' ? 'know' : 'do', from)
201  return { result: `${isNew ? 'Pinned' : 'Already pinned'} as T${id}. Unpin it with unpin_for_owner once it is done.` }
202}
203
204export async function unpinForOwner($: EngineInterface, e: { ids?: string[] }) {
205  const ids = new Set(((e.ids ?? []) as string[]).map(s => Number(String(s).replace(/^T/i, ''))))
206  let gone: Todo[] = []
207  let left = 0
208  await update($, todos, list => {
209    gone = list.filter(t => ids.has(t.id))
210    const kept = list.filter(t => !ids.has(t.id))
211    left = kept.length
212    return kept
213  })
214  const missing = [...ids].filter(id => !gone.some(t => t.id === id)).map(id => `T${id}`)
215  return {
216    result: `Unpinned ${gone.length}.${missing.length ? ` Not pinned (already done or wrong id): ${missing.join(', ')}.` : ''} ${left} item(s) still pinned for the person.`,
217  }
218}
219
hooks/session.ts 10 lines
1import { PREFIX } from './defaults'
2
3// What this load of the mod knows about its session. A reload starts the module over,
4// and session.start fills these in again: the folder, the command prefix from the
5// settings, and the band's id (from its last drawing) for moving its focus ring.
6export const session = { cwd: '', prefix: PREFIX, bandId: '' }
7
8// A command as the person types it, e.g. cmd('wrap-up') is /hrd-wrap-up.
9export const cmd = (name: string) => `/${session.prefix}${name}`
10
hooks/settings.tsx 388 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3import type { SettingsView } from '../types'
4import type { BandRender } from './band'
5import { refresh } from './board'
6import { allProfiles, BUILT_IN, customized, OPTIONAL, readJson, userConfigPath } from './config'
7import { CLOSE_IDLE_MINUTES, PREFIX, PRUNE_MINUTES, RESET_AT, TARGET } from './defaults'
8import { tilde } from './format'
9import { session } from './session'
10import { sideOpen, toggleSide } from './side'
11
12// /hrd-config: the settings, listed on the band above the prompt as /config lists
13// its own. Enter flips a switch, steps a choice (›) or opens a field; each change
14// is written to the user's or the folder's herdr-agents.json at once.
15
16const settingsView = atom(
17  { plugin: 'herdr-agents', key: 'settings' } as const,
18  {
19    scope: 'user',
20    paths: { user: '', folder: '' },
21    home: '',
22    user: {},
23    folder: {},
24    profiles: [],
25  } as SettingsView,
26)
27
28export async function settingsOpen($: EngineInterface) {
29  return (await read($, settingsView)).isOpen === true
30}
31
32// The settings pane's values for a picker that writes nothing, or `null`.
33export const INHERIT = '*inherit'
34
35export const NONE = '*none'
36
37export const EDIT = 'cfg-edit'
38
39// One row of the settings pane. `text` rows open a field, `switch` and `choice` rows
40// change on Enter, `fixed` rows only show. `raw` is what this file holds; `value`
41// what is in effect, `isDefault` when no file sets it.
42export type SettingRow = {
43  key: string
44  label: string
45  kind: 'text' | 'switch' | 'choice' | 'fixed'
46  value: string
47  raw: string
48  isDefault: boolean
49  hint: string
50  detail?: string
51}
52
53// The settings typed into a field, with their label, default, unit and hint.
54type TextSetting = 'target' | 'resetAt' | 'statePath' | 'pruneMinutes' | 'closeIdleMinutes' | 'commandPrefix'
55
56export const TEXT_SETTINGS: [key: TextSetting, label: string, initial: unknown, unit: string, hint: string][] = [
57  ['target', 'Workers to keep busy', TARGET, '', 'How many workers the coordinator keeps running.'],
58  [
59    'resetAt',
60    'Compact the coordinator past',
61    RESET_AT,
62    ' tokens',
63    'Past this much context the coordinator saves its state and is compacted.',
64  ],
65  ['statePath', 'State file', 'COORDINATOR-STATE.md', '', "The coordinator's notes: relative to the folder, absolute, or ~/…."],
66  [
67    'pruneMinutes',
68    'Prune merged worktrees every',
69    PRUNE_MINUTES,
70    ' min',
71    'How often merged worktrees are removed; 0 turns it off. New sessions.',
72  ],
73  [
74    'closeIdleMinutes',
75    'Close idle worker tabs after',
76    CLOSE_IDLE_MINUTES,
77    ' min',
78    "A reported worker's tab closes once idle this long; 0 keeps it. New sessions.",
79  ],
80  ['commandPrefix', 'Command prefix', PREFIX, '', 'What every command starts with. New sessions.'],
81]
82
83export const NUMBERS: Record<string, number> = { target: 1, resetAt: 10_000, pruneMinutes: 0, closeIdleMinutes: 0 }
84
85export async function loadSettings($: EngineInterface, note?: string, error?: string) {
86  const paths = { user: await userConfigPath($), folder: `${session.cwd}/.claude/herdr-agents.json` }
87  const user = await readJson($, paths.user)
88  const folder = await readJson($, paths.folder)
89  const home = (await $.env.get('HOME')) ?? ''
90  const all = await allProfiles($)
91  const old = await read($, settingsView)
92  const scope = old.paths.user ? old.scope : (await $.fs.exists(paths.folder)) ? 'folder' : 'user'
93  await update($, settingsView, p => ({
94    ...p,
95    scope,
96    paths,
97    home,
98    user,
99    folder,
100    profiles: Object.entries(all).map(([name, p]) => ({
101      name,
102      note: p.note ?? p.kind,
103      builtIn: BUILT_IN.includes(name),
104      customized: customized(name, p),
105    })),
106    note,
107    error,
108  }))
109}
110
111export function settingRows(pane: SettingsView): SettingRow[] {
112  const mine = pane[pane.scope]
113  const merged = { ...pane.user, ...pane.folder }
114  // Where a value this file leaves out comes from.
115  const source = (key: TextSetting) => (pane.scope === 'folder' && pane.user[key] !== undefined ? ' (all folders)' : ' (default)')
116  const off: string[] = merged.disable ?? []
117  const small = mine.smallTasks !== undefined ? mine.smallTasks : merged.smallTasks
118  const builtIn = (name: string): SettingRow => {
119    const p = pane.profiles.find(x => x.name === name)
120    const detail = `${p?.note ?? ''}${p?.customized ? ' · customized' : ''}`
121    if (!OPTIONAL.includes(name))
122      return {
123        key: `cfg-off-${name}`,
124        label: `${name[0]!.toUpperCase()}${name.slice(1)}`,
125        kind: 'fixed',
126        value: 'always on',
127        raw: '',
128        isDefault: true,
129        hint: '',
130      }
131    return {
132      key: `cfg-off-${name}`,
133      label: `${name[0]!.toUpperCase()}${name.slice(1)}`,
134      kind: 'switch',
135      value: off.includes(name) ? 'off' : 'on',
136      raw: '',
137      isDefault: !off.includes(name),
138      detail,
139      hint: {
140        planner: 'Plans each issue first. Off: the builder plans before it builds.',
141        reviewer: 'Reviews each PR. Off: the coordinator reviews the diff and checks itself.',
142        debugger: 'Takes over after two failed fix rounds. Off: the coordinator asks you.',
143      }[name]!,
144    }
145  }
146  return [
147    {
148      key: 'cfg-scope',
149      label: 'Edit settings for',
150      kind: 'choice',
151      value: pane.scope === 'user' ? 'all folders' : 'this folder',
152      raw: '',
153      isDefault: false,
154      detail: tilde(pane.paths[pane.scope], pane.home),
155      hint: "Which file the changes go to. This folder's file overrides the one for all folders.",
156    },
157    ...TEXT_SETTINGS.map(
158      ([key, label, initial, unit, hint]): SettingRow => ({
159        key: `cfg-${key}`,
160        label,
161        kind: 'text',
162        value: mine[key] !== undefined ? `${mine[key]}${unit}` : `${merged[key] ?? initial}${unit}${source(key)}`,
163        raw: mine[key] === undefined ? '' : String(mine[key]),
164        isDefault: mine[key] === undefined,
165        hint,
166      }),
167    ),
168    {
169      key: 'cfg-wrapUp',
170      label: 'Wrap up',
171      kind: 'switch',
172      value: mine.wrapUp === true ? 'on' : 'off',
173      raw: '',
174      isDefault: mine.wrapUp !== true,
175      hint: 'On: no new workers start, the running ones finish. In the all-folders file it holds every coordinator.',
176    },
177    {
178      key: 'cfg-view',
179      label: 'Show workers in',
180      kind: 'choice',
181      value: (mine.view ?? merged.view) === 'side' ? 'the sidebar' : 'the band',
182      raw: '',
183      isDefault: mine.view === undefined,
184      hint: 'The band above the prompt or the sidebar beside the chat; only one shows. /hrd-side switches for this session.',
185    },
186    builtIn('planner'),
187    builtIn('builder'),
188    builtIn('reviewer'),
189    builtIn('debugger'),
190    {
191      key: 'cfg-smallTasks',
192      label: 'Small tasks go to',
193      kind: 'choice',
194      value: small ? small : 'nobody',
195      raw: '',
196      isDefault: mine.smallTasks === undefined,
197      hint: 'A profile the coordinator may give small, well-defined tasks.',
198    },
199  ]
200}
201
202export async function pressRow($: EngineInterface, key: string) {
203  const pane = await read($, settingsView)
204  const row = settingRows(pane).find(r => r.key === key)
205  if (!row) return
206  if (row.kind === 'text') {
207    await update($, settingsView, p => ({ ...p, editing: key, note: undefined, error: undefined }))
208    await $.ui.focus({ requestId: session.bandId, key: EDIT }).catch(() => {})
209  } else if (key === 'cfg-scope') {
210    await update($, settingsView, p => ({
211      ...p,
212      scope: p.scope === 'user' ? ('folder' as const) : ('user' as const),
213      note: undefined,
214      error: undefined,
215    }))
216  } else if (key === 'cfg-wrapUp') {
217    await saveSetting($, 'wrapUp', row.value === 'on' ? '' : 'true')
218  } else if (key === 'cfg-view') {
219    await saveView($, row.value === 'the sidebar' ? 'band' : 'side')
220  } else if (key === 'cfg-smallTasks') {
221    // Steps through nobody and each profile that is on.
222    const merged = { ...pane.user, ...pane.folder }
223    const off: string[] = merged.disable ?? []
224    const choices = [NONE, ...pane.profiles.filter(p => !off.includes(p.name)).map(p => p.name)]
225    const now = row.value === 'nobody' ? NONE : row.value
226    await saveSmallTasks($, choices[(choices.indexOf(now) + 1) % choices.length]!)
227  } else if (key.startsWith('cfg-off-')) {
228    await toggleOff($, key.slice('cfg-off-'.length))
229  }
230}
231
232export async function closeSettings($: EngineInterface) {
233  await update($, settingsView, p => ({ ...p, isOpen: false, focused: undefined, editing: undefined, note: undefined, error: undefined }))
234}
235
236export async function editDone($: EngineInterface, key: string, value: string) {
237  await update($, settingsView, p => ({ ...p, editing: undefined }))
238  await saveSetting($, key.slice('cfg-'.length), value)
239  await $.ui.focus({ requestId: session.bandId, key }).catch(() => {})
240}
241
242// Reads the file the pane edits, changes it with `fn` and writes it back. A file that is
243// not valid JSON is left alone, so a hand edit in progress is never overwritten.
244export async function editConfig($: EngineInterface, fn: (c: Record<string, unknown>) => string) {
245  const { scope, paths } = await read($, settingsView)
246  const path = paths[scope]
247  let c: Record<string, unknown> = {}
248  if (await $.fs.exists(path)) {
249    try {
250      c = JSON.parse(await $.fs.read(path))
251    } catch {
252      return loadSettings($, undefined, `${path} is not valid JSON; fix it by hand first.`)
253    }
254  }
255  const note = fn(c)
256  await $.fs.write(path, `${JSON.stringify(c, null, 2)}\n`)
257  await loadSettings($, `${note} in ${path}.`)
258  void refresh($)
259}
260
261export async function saveSetting($: EngineInterface, key: string, raw: string) {
262  const text = raw.trim()
263  let value: unknown = text
264  if (text && key in NUMBERS) {
265    value = Number(text)
266    if (!Number.isInteger(value) || (value as number) < NUMBERS[key]!)
267      return loadSettings($, undefined, `${key} must be a whole number of at least ${NUMBERS[key]}.`)
268  }
269  if (text && key === 'commandPrefix' && !/^[a-z0-9_-]{0,20}$/.test(text))
270    return loadSettings($, undefined, 'commandPrefix takes up to 20 lowercase letters, digits, _ and -.')
271  if (key === 'wrapUp') value = text === 'true'
272  await editConfig($, c => {
273    if (!text || value === false) {
274      delete c[key]
275      return `Removed ${key}`
276    }
277    c[key] = value
278    return `Saved ${key} = ${text}`
279  })
280}
281
282export async function saveSmallTasks($: EngineInterface, value: string) {
283  const { scope } = await read($, settingsView)
284  await editConfig($, c => {
285    // Nobody is the default for all folders; a folder writes null to override theirs.
286    if (value === INHERIT || (value === NONE && scope === 'user')) delete c.smallTasks
287    else c.smallTasks = value === NONE ? null : value
288    return value === INHERIT ? 'Removed smallTasks' : `Saved smallTasks = ${value === NONE ? 'nobody' : value}`
289  })
290}
291
292// Saves where the workers show and shows them there now. The band is the default for all
293// folders; a folder writes it to override theirs.
294export async function saveView($: EngineInterface, view: 'band' | 'side') {
295  const { scope } = await read($, settingsView)
296  await editConfig($, c => {
297    if (view === 'band' && scope === 'user') delete c.view
298    else c.view = view
299    return `Saved view = ${view}`
300  })
301  if ((view === 'side') !== (await sideOpen($))) await toggleSide($)
302}
303
304// Turns a built-in profile off or on in the file the pane edits. A folder file without
305// `disable` starts from the one for all folders, since its own replaces that one.
306export async function toggleOff($: EngineInterface, name: string) {
307  const pane = await read($, settingsView)
308  const base: string[] = pane[pane.scope].disable ?? (pane.scope === 'folder' ? pane.user.disable : undefined) ?? []
309  const isOff = base.includes(name)
310  const next = isOff ? base.filter(n => n !== name) : [...base, name]
311  await editConfig($, c => {
312    if (next.length || (pane.scope === 'folder' && pane.user.disable?.length)) c.disable = next
313    else delete c.disable
314    return `Turned ${name} ${isOff ? 'on' : 'off'}`
315  })
316}
317
318export async function openSettings($: EngineInterface) {
319  await loadSettings($)
320  await update($, settingsView, p => ({ ...p, isOpen: true, focused: undefined, editing: undefined }))
321}
322
323// The band's focus ring: while the settings show, ❯ marks the row it is on.
324export async function trackFocus($: EngineInterface, element: string | undefined) {
325  if ((await read($, settingsView)).isOpen)
326    await update($, settingsView, p => ({ ...p, focused: element, editing: element === EDIT ? p.editing : undefined }))
327}
328
329// The settings drawn on the band, or undefined while they are closed (or on the mobile
330// app, which draws no fields yet).
331export async function drawSettings($: EngineInterface, e: BandRender) {
332  const settings = await read($, settingsView)
333  if (settings.isOpen && !e.props.hasSurvey && e.surface !== 'mobile') {
334    const { Box, Button, Input, Text } = $.ui.resolve(e)
335    const list = settingRows(settings)
336    const focused = list.find(r => r.key === settings.focused)
337    const width = Math.max(...list.map(r => r.label.length)) + 4
338    const hint = focused
339      ? [focused.hint, focused.detail].filter(Boolean).join('  ·  ')
340      : tilde(settings.paths[settings.scope], settings.home)
341    return (
342      <Box flexDirection="column">
343        {list.map(row =>
344          row.key === settings.editing ? (
345            <Input
346              key={EDIT}
347              label={`❯ ${row.label.padEnd(width)}`}
348              value={row.raw}
349              placeholder={row.value}
350              submitLabel="save"
351              onSubmit={(value: string) => void editDone($, row.key, value)}
352            />
353          ) : row.kind === 'fixed' ? (
354            <Text dimColor>{`  ${row.label.padEnd(width)}${row.value}`}</Text>
355          ) : (
356            <Button key={row.key} plain onPress={() => void pressRow($, row.key)}>
357              {`${settings.focused === row.key ? '❯' : ' '} ${row.label.padEnd(width)}`}
358              <Text dimColor={row.isDefault}>{row.value}</Text>
359              {row.kind === 'choice' ? <Text dimColor>{' ›'}</Text> : null}
360            </Button>
361          ),
362        )}
363        <Text> </Text>
364        {/* One line whatever the row's hint, so moving through the rows never
365            changes the band's height. */}
366        <Text dimColor wrap="truncate-end">{`  ${hint}`}</Text>
367        {settings.error ? (
368          <Text color="error">{`  ${settings.error}`}</Text>
369        ) : settings.note ? (
370          <Text color="success">{`  ${settings.note}`}</Text>
371        ) : null}
372        {/* Done comes first: the keys after it change with the focus, and a click
373            focuses before it presses, so a Done after them would move away under it. */}
374        <Box columnGap={2} paddingLeft={2}>
375          <Button key="cfg-done" plain role="dismiss" onPress={() => void closeSettings($)}>
376            Done
377          </Button>
378          <Text dimColor wrap="truncate-end">
379            {settings.editing
380              ? 'Enter to save · empty for the default · ↑↓ to leave it'
381              : `${settings.focused ? '' : 'alt+d to enter · '}Enter to change · ↑↓ to move · Esc back to the prompt`}
382          </Text>
383        </Box>
384      </Box>
385    )
386  }
387}
388