Active-work tracking for Claude Code fleets: forge state, a work ledger of who owns which issue, event delivery to agents, model routing per issue, and a live…

Active-work tracking for Claude Code fleets. helm watches GitHub and your local checkouts, keeps a ledger of which session and agent owns which issue, delivers repository events to the agent that asked for them, routes each issue to a model and effort, and shows all of it live, in the terminal and on a local web page with a decision inbox.
It is built for one way of working: a coordinator session, a session per repository that owns that repository's issues, and an issue agent per issue spawned by the repository session. Every piece is useful on its own too.
/plugin install helm --marketplace octalide/helm@main
or from a shell:
claude plugin marketplace add octalide/helm@main
claude plugin install helm@helm
helm is a function-hook plugin, which is early access. Turn on CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the env block of settings.json. It needs node 24 or newer and gh logged in (gh auth login). helmd uses the token gh holds and keeps none of its own.
GitHub ──(etag probes, graphql)──┐
local git (worktrees, branches) ─┤
▼
helmd (one per user)
forge cache · ledger · subscriptions
│ unix socket │ 127.0.0.1:7468
┌───────────┼───────────┐ ▼
session session session web page
(coordinator) (repo A) (repo B) live fleet, decision inbox
│ dispatch
issue agents ── report, watch, view, log
helmd is the only process that talks to GitHub. The first session to need it starts it, and a session that finds an older one replaces it. Either way the session runs the newest helmd installed beside its mod that speaks its protocol, so whichever session starts or replaces it lands on the newest. It polls each repository in use: those a live session works in, those with unfinished work or a subscription, and any a tool asked about lately. Two conditional probes per poll cost nothing when nothing moved, and the snapshot (open and recent issues and PRs, every check on each PR head, last comment and review) is read only when a probe moved. Active repositories poll every 20 s, the rest every 3 minutes. Jobs and their steps are read live while a run is in flight. Everything helmd holds survives a restart.
The mod binds each session to helmd. It registers the session and its agents, streams the session's deliveries, serves the tools, draws the pane, and tells repository and coordinator sessions how to work with helm through a section of their system prompt, so no instruction file has to.
Set the role with HELM_ROLE=coordinator|repo|other at launch, or with /helm role coordinator. A role asked for wins. Otherwise helmd keeps the role a session already has, and gives a new one the role of the session it goes on from after a /clear or a resume, or else makes it the repo session of the repository it runs in. The mod takes its role and repository from helmd's answer.
backlog, starts agents with dispatch, and hears its own work move ([helm work] deliveries) without polling.view with what: fleet or what: tree), hears each epic's progress, every decision for the person and any work that needs someone, and talks to repository sessions with SendMessage.dispatch. It reports as it goes, its plan with each step's progress among it, waits on CI by subscribing and ending its turn, and stops with a question that goes to the session that owns its work, which answers it or escalates it to the person. The answer resumes it.| tool | does |
|---|---|
status | this session's role, work, the decisions addressed to it and its own waiting on the person, subscriptions and letters in flight |
view | work, fleet, tree (epics and sub-issues with progress rolled up), issues, issue, prs, pr (with live jobs), runs, worktrees, branches, decisions, sessions, from the shared cache |
log | a CI job's log trimmed to its errors, a grep or a tail; or every failed job of a run |
watch | subscribe to a repository, issue, PR, branch, run or tag. A subagent's subscription delivers to that subagent |
report | where an agent's work stands: working (claims the issue), waiting, blocked with a question, ready, stopped, abandoned, plus its plan and choices made without the person, recorded for review |
dispatch | start an issue agent per issue: claim, route to a tier, spawn, log the pick |
backlog | the session's queue of issues |
decide | list, answer, escalate to the person or dismiss decisions |
/helm shows the session's binding, and /helm pane, /helm web, /helm role … and /helm restart do what they say.
/helm pane opens a dashboard beside the transcript, with four tabs. Each tab key works while the pane has the focus.
| key | tab | shows |
|---|---|---|
1 | Work | the session's work, or for a coordinator every session's: a bar of it by phase, then each item with its plan progress and next step, its checks with the running step or the failed checks, and its agent, tier and PR |
2 | Epics | each epic touching the session, with its percent, rollup bar and counts, and the work moving under it |
3 | CI | runs in flight with their job bar and running steps, then runs finished in the last half hour |
4 | Inbox | what is addressed to this session: its agents' questions and its stalls, and for a coordinator the person's decisions too; an option answers one in place, escalate hands it to the person and dismiss closes it. A written answer goes on the web page |
Above the prompt, one line appears while something needs you, and the status line counts work in progress.
Events reach whoever subscribed. A letter for the main loop rides the next tool result while a turn runs. Between turns, every letter waiting goes as one prompt that starts a turn, and what lands before that turn ends rides it or waits for its end. A letter for a subagent rides that agent's next tool call. After 60 s without one, or once the agent has ended its turn, it goes as a message that resumes the agent. If the engine refuses the message, the letter is relayed to the main loop and the agent's subscriptions are retired. Letters that go together read as one, a work item's older phases folded into its newest (working → draft → ci). helmd hands each letter out once. After a plugin reload, the module instance it replaced stands down at its next frame, so only the live one delivers.
CI arrives as one verdict per PR head (ci settled success or failure, naming each failed check with the run to read), one stall notice when checks have not finished within an hour, and run completions on branches and tags.
Work moves through queued → working → draft → ci → ready → done, with failing, blocked, stalled and parked beside them. Phases are derived, not set: from the agent's reports, whether its agent is alive, the PR that closes the issue or sits on its branch, that PR's checks, and the issue's labels and dependencies. A stalled item (no live agent, work not done) and a stalled or failing CI raise a decision on their own, and clear it once the condition passes. A stall goes to the session that owns the work, and to the person once that session is gone with no successor (see Succession), and a failing long-lived branch goes to the person. A dismissed one stays dismissed until its condition changes (a new PR head, a different agent, a different phase) or ends. Work set aside on purpose, labelled blocked or parked or blocked by an open issue, is parked while nobody is on it: it raises nothing and needs no attention. Work whose agent reported stopped is parked too, and what its subscriptions deliver goes to the session that owns the work instead of resuming the agent, until the issue is dispatched again (the new agent takes over the subscriptions), the agent reports working, or its session messages it back to work. A report polls its repository at once, so the phase keeps up with what the agent just did.
A repo session that registers in a repository whose other repo sessions are all gone, or is the one live repo session left when another goes, takes over their unfinished work: the backlog in its order, the claims, the subscriptions, and the open decisions addressed to them or handed to the person only because their session was gone. A decision a session escalated itself stays the person's. Each item records adopted: { from, at }, and the new session hears it as one [helm adopted] delivery listing what it took and which items to dispatch again. An agent that came with adopted work is gone with its old session: what its subscriptions deliver goes to the new session's main loop, and the agent a new dispatch starts takes the subscriptions over. Nothing is taken from a live session, and a coordinator or an other session never adopts repository work. A session resumed under its own id keeps everything, as before. After a /clear or an in-process resume the process goes on under a new id, and the mod names the old one when it registers, so helmd hands it over whole and at once.
The adopting session records what it took. Once it is no longer the repo session of that repository (it takes another role or another repository), it gives back what it has not acted on: work whose owner is unchanged, whose agent is the one it came with and that nobody has reported on since, with the subscriptions and open decisions that came with it and the open decisions addressed to it about that work. They go back to the session they came from and on from there by the same succession, to a live repo session of the repository or, for a decision, to the person. The session hears it as one [helm gave back] delivery.
GitHub's sub-issues draw the tree. Every open issue with sub-issues that no other issue in a watched repository holds is a root epic, and each node joins its work item: phase, agent, tier and plan progress. Sub-issues in other repositories nest under their parent, and a sub-epic in a repository helm does not poll is counted from its summary. Each epic rolls up its leaves: done, active, in CI, ready, needing attention, queued, parked and unowned. Sub-issues are read again only when their parent moved, or every 10 minutes.
Each work item keeps when it entered each phase, and finished work stays 30 days, so the page can draw a timeline. A coordinator hears an epic as one [helm epic] delivery whenever anything under it moves, carrying its rollup and every phase change under it in that batch, instead of a delivery per child. Work that needs someone still arrives at once.
When an issue agent's loop ends, helmd looks for live processes whose working directory is inside that work's worktree (Linux /proc, and a host without it reports nothing). What it finds goes on the work item as leftovers and to the owner as a [helm work] delivery tagged leftovers, shown in the pane and the drawer. Each local scan drops a listed process that has exited or left the worktree. helm never kills them: whoever owns the work decides.
dispatch sends each issue to a tier: a model and an effort with a description of the work that belongs there. The judge (claude-haiku-5-5 by default) reads the issue against the tiers and answers a tier, a confidence and a reason. Each pick is recorded for review, a record that waits on nobody, and answering it with another tier reroutes the issue. Name a tier in dispatch to skip the judge. A session registers an agent type for every tier and for the model and effort of every work item it owns, so an agent dispatched on a tier since removed can still be resumed. A session picks up a changed tier table on /reload-plugins.
The default tiers:
| tier | model | effort | for |
|---|---|---|---|
| mechanical | claude-haiku-5-5 | high | version and pin bumps, renames, moves, docs, pattern-following data rows, finishing a done PR, one-file fixes with a stated cause |
| light | claude-sonnet-5-5 | medium | small contained work in one module with a stated design |
| standard | claude-opus-5-5 | medium | ordinary work in one subsystem with clear acceptance |
| deep | claude-opus-5-5 | high | cross-subsystem or contract changes, codegen, concurrency, soundness, design-heavy work, unknown root causes |
| frontier | claude-fable-5-1 | high | reserved for decision-heavy work: architecture, contract or language design, research-grade problems, what earlier tiers failed on. Never an implementation workhorse |
A judge answer that names no tier falls back to routing.fallback, standard by default.
http://127.0.0.1:7468/, or /helm web. It is a dashboard with a view per question, and each view updates live:
| view | shows |
|---|---|
| Overview | active work as the headline, tiles for what waits on you, what needs attention, what is in CI, ready and done this week; the attention queue, runs in flight, every epic's progress, the pipeline by phase, throughput per day and each session |
| Board | work as cards in phase columns (queued, working, draft, in ci, ready, attention, parked, done), each with its agent, tier, plan progress and checks; lanes by session, epic, repository or tier |
| Epics | the sub-issue tree across repositories, each epic with its rollup bar, each leaf with its phase, plan and agent; drill into any epic |
| Timeline | a lane per work item of the phases it went through over 6 hours to 30 days, and the median time work spends in each phase |
| CI | runs in flight with every job and step, pass rate and run length, each workflow's recent outcomes, and failed runs with their logs |
| Agents | each live session's agents, their model and effort, and the work each is on |
| Routing | picks per tier by outcome, the judge's confidence, and every pick with its reason |
| Inbox | what is yours to decide, each card marked for you: questions sessions hand on or ask themselves, stalls and failures nobody else owns, answered or dismissed in place. Below, muted and never counted, what sessions are handling, each naming its session, so you can read it or step in |
| Review | records of what was decided without you, newest first: choices agents and sessions made, which take feedback or are marked reviewed, and routing picks, which can still be rerouted |
| Repos | each repository's PRs, issues, runs, worktrees and branches, and the GitHub budget left |
| Activity | every event by day, by kind |
Clicking a work item opens its detail: its phases with how long each took, its plan, CI, routing, decisions and worktree. Filters for repository, session, epic and tier, plus a search, apply to every view and live in the URL, so a filtered view can be bookmarked. ctrl k opens a palette that jumps to any view, issue, epic, repository or session. The digits open the views and r opens Review, / searches, j and k walk the cards, t toggles the theme, and ? lists the keys.
The page is served on 127.0.0.1 only. A request must name this server as its Host, and a write must come from this page.
~/.config/helm/config.json, every field optional:
{
"roots": ["~/dev/src"],
"repos": ["owner/name"],
"web": { "port": 7468 },
"poll": { "active": 20, "idle": 180, "local": 15, "stallHours": 1, "goneSeconds": 120 },
"routing": { "judge": "claude-haiku-5-5", "review": 0.7, "fallback": "standard", "tiers": [ ... ] }
}
roots are searched for checkouts by their origin remote. repos are polled whether or not anything references them. routing.tiers replaces the table whole, and routing.fallback must name one of its tiers. A repository can set its own routing in .helm/config.json.
helmd watches the file and applies an edit live, with no restart. An edit that fails to parse or check is logged to the daemon log and the running config kept.
State lives under $XDG_STATE_HOME/helm and the socket under $XDG_RUNTIME_DIR/helm. HELM_HOME puts everything under one directory, which is how a second daemon runs beside the real one.
helmd start | stop | restart | status | serve | stream <session> | version
bin/helmd runs it from a checkout. Sessions start it on their own. restart hands over without a gap: the new daemon takes the lock and the socket while the old one still answers, then stops it and serves once it has saved.
npm ci
npm run typecheck
npm test
claude plugin validate .
claude --plugin-dir .
The engine follows $ into the hooks module alone, so everything that reaches the engine is in hooks/helm.tsx. Logic lives in plain modules under src/mod (the session side), src/daemon (helmd) and src/core (the contract both sides share).
hooks/helm.tsx 506 lines1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, Register } from 'claude-code';
3import { helmPaths, type PathEnv } from '../src/core/paths.ts';
4import { daemonAction, PROTOCOL, type StreamFrame } from '../src/core/protocol.ts';
5import { isRepoName, repoOfRemote } from '../src/core/repo.ts';
6import type { AgentRecord, AgentStatus, Effort, Fleet, RepoName, SessionRole } from '../src/core/types.ts';
7import { HelmClient, HelmError } from '../src/mod/client.ts';
8import { type Install, newestInstall } from '../src/mod/install.ts';
9import { type DispatchPort, dispatchTool, issueAgentName } from '../src/mod/dispatch.ts';
10import { Mailbox } from '../src/mod/mailbox.ts';
11import { type Tool, TOOLS } from '../src/mod/tools.ts';
12import { bandRow, isTab, paneRows, type Row, type Self, statusText, type Tab } from '../src/mod/view.ts';
13import { PLUGIN, ROLES, type Runtime, toolEnv } from './runtime.ts';
14
15type $ = EngineInterface;
16
17// what a tool that reaches the engine is handed: plain functions over $, built here per call
18type Port = DispatchPort;
19
20const HEARTBEAT_MS = 15_000;
21const RECONNECT_MS = 3_000;
22// the pane redraws from helmd at most this often, however fast changes come
23const REFRESH_MS = 1_500;
24const PANE = 'helm';
25
26// the fleet itself stays in the module, since the state contract holds only self-contained data; the atom is the
27// stamp that tells a drawing to read it again
28// the role prompts, read at session start: the coordinator's, and the repository session's with {{repo}} in it
29let rolePrompts: { coordinator: string; repo: string } | undefined;
30
31function rolePrompt(r: Runtime): string | undefined {
32 if (!rolePrompts) return undefined;
33 if (r.role === 'coordinator') return rolePrompts.coordinator;
34 if (r.role === 'repo') return rolePrompts.repo.replaceAll('{{repo}}', r.repo ?? 'its repository');
35 return undefined;
36}
37
38const fleetAt = atom({ plugin: 'helm', key: 'fleetAt' } as const, 0);
39const paneTab = atom({ plugin: 'helm', key: 'paneTab' } as const, 'work' as Tab);
40// the module instance that holds the session's binding. a reload swaps the hooks but can leave the replaced
41// instance's stream running, its mailbox deaf to turns, so each instance stamps itself here at session.start and
42// one that finds another stamp stands down
43const binder = atom({ plugin: 'helm', key: 'binder' } as const, '');
44let fleet: Fleet | undefined;
45
46let rt: Runtime | undefined;
47
48async function pathEnv($: $): Promise<PathEnv> {
49 const env: PathEnv = { HOME: (await $.env.get('HOME')) ?? '/' };
50 const set = (k: keyof PathEnv, v: string | undefined) => {
51 if (v) env[k] = v;
52 };
53 set('XDG_RUNTIME_DIR', await $.env.get('XDG_RUNTIME_DIR'));
54 set('XDG_STATE_HOME', await $.env.get('XDG_STATE_HOME'));
55 set('XDG_CONFIG_HOME', await $.env.get('XDG_CONFIG_HOME'));
56 set('XDG_CACHE_HOME', await $.env.get('XDG_CACHE_HOME'));
57 set('HELM_SOCKET', await $.env.get('HELM_SOCKET'));
58 set('HELM_HOME', await $.env.get('HELM_HOME'));
59 return env;
60}
61
62async function clientFor($: $): Promise<HelmClient> {
63 const paths = helmPaths(await pathEnv($));
64 return new HelmClient(async (url, init) => {
65 const res = await $.http.fetch(url, init);
66 return { status: res.status, ok: res.ok, text: res.text };
67 }, paths.socket);
68}
69
70function daemonArgv(root: string, cmd: string, ...rest: string[]): string[] {
71 return ['node', '--disable-warning=ExperimentalWarning', `${root}/src/daemon/main.ts`, cmd, ...rest];
72}
73
74// the install whose helmd this mod starts or replaces helmd with, read again each time, since a newer one can be
75// installed while the session runs
76function daemonInstall($: $, own: Install): Promise<Install> {
77 return newestInstall(own, {
78 dirs: async (path) => (await $.fs.list(path)).filter((e) => e.kind === 'dir').map((e) => e.name),
79 read: (path) => $.fs.read(path),
80 });
81}
82
83let reloadSaid = false;
84
85// helmd answering at the newest installed version or newer; an older one is replaced, a newer one is used as it is
86async function ensureDaemon($: $, client: HelmClient, own: Install): Promise<void> {
87 const up = await client.health().catch(() => undefined);
88 const install = await daemonInstall($, own);
89 const cmd = daemonAction(up, install.version);
90 if (cmd === 'use') return;
91 if (cmd === 'reload') {
92 if (!reloadSaid) $.ui.log(`helm: helmd ${up?.version} speaks protocol ${up?.protocol}, newer than this session's mod (${PROTOCOL}): run /reload-plugins`);
93 reloadSaid = true;
94 return;
95 }
96 const r = await $.process.run(daemonArgv(install.root, cmd), { timeoutMs: 30_000 });
97 if (r.exitCode !== 0) throw new Error(`helmd ${cmd} failed: ${(r.stderr || r.stdout).trim()}`);
98}
99
100async function sessionRepo($: $): Promise<RepoName | undefined> {
101 const r = await $.session.repo();
102 return r?.remote ? repoOfRemote(r.remote) : undefined;
103}
104
105async function agentRecords($: $): Promise<AgentRecord[]> {
106 return (await $.agent.list()).map((a) => ({
107 id: a.id,
108 type: a.type,
109 description: a.description,
110 status: a.status as AgentStatus,
111 ...(a.name ? { name: a.name } : {}),
112 ...(a.parentId ? { parentId: a.parentId } : {}),
113 }));
114}
115
116// from: the session this process went on from, which helmd hands over to this one whole. the role goes only when one
117// was asked for, and the role and repository are what helmd answers, which keeps a known session's own
118async function bind($: $, r: Runtime, from?: string): Promise<void> {
119 r.session = await $.session.id();
120 const s = await r.client.register({
121 id: r.session,
122 cwd: await $.session.cwd(),
123 protocol: PROTOCOL,
124 ...(r.asked ? { role: r.asked } : {}),
125 ...(r.checkout ? { repo: r.checkout } : {}),
126 ...(from && from !== r.session ? { from } : {}),
127 });
128 r.role = s.role;
129 if (s.repo) r.repo = s.repo;
130 else delete r.repo;
131}
132
133// issue agent types registered by this load of the module, by name
134const issueTypes = new Set<string>();
135let issuePrompt: string | undefined;
136
137async function issueAgentType($: $, model: string, effort: Effort): Promise<string> {
138 const name = issueAgentName(model, effort);
139 if (!issueTypes.has(name)) {
140 issuePrompt ??= await $.fs.read(`${$.plugin.root}/prompts/issue.md`);
141 await $.agent.register({
142 name,
143 description: `Implements one GitHub issue to a merge-ready PR on ${model} at ${effort} effort. Started by helm dispatch only.`,
144 prompt: issuePrompt,
145 model,
146 effort,
147 disallowedTools: ['AskUserQuestion'],
148 });
149 issueTypes.add(name);
150 }
151 return `${PLUGIN}:${name}`;
152}
153
154// every tier this session can route to, plus the model and effort of every work item it owns, registered up front so a
155// dispatch in its first turn finds them and an agent on a tier since removed can still be resumed
156async function registerTiers($: $, client: HelmClient, repo: RepoName | undefined, session: string): Promise<void> {
157 const { routing } = await client.config(repo);
158 for (const t of routing.tiers) await issueAgentType($, t.model, t.effort);
159 const { work } = await client.fleet(true);
160 for (const w of work) if (w.owner === session && w.routing) await issueAgentType($, w.routing.model, w.routing.effort);
161}
162
163function port($: $): Port {
164 return {
165 now: Date.now,
166 agentType: (model, effort) => issueAgentType($, model, effort),
167 spawn: async (a) => {
168 const r = await $.agent.spawn(a);
169 return r.deny !== undefined ? { deny: r.deny } : r.agentId ? { agentId: r.agentId } : {};
170 },
171 complete: async (a) => {
172 const r = await $.model.complete({ ...a, maxTokens: 400, effort: 'low', timeoutMs: 45_000 });
173 return r.isAnswered ? { text: r.text } : { failed: r.reason };
174 },
175 };
176}
177
178// false once a later instance of this module holds the binding, which this one learns of here and stands down for:
179// its timers and mailbox stop, and the stream it reads ends. a stamp that cannot be read is another's
180async function holds($: $, r: Runtime): Promise<boolean> {
181 if (!r.alive) return false;
182 const stamp = await read($, binder).catch(() => undefined);
183 if (stamp === '' || stamp === r.instance) return true;
184 r.alive = false;
185 r.timers.forEach((t) => t.cancel());
186 r.mailbox.stop();
187 $.ui.log(`helm: instance ${r.instance} stood down for ${stamp ?? 'an unreadable binding'}`, { to: 'debug' });
188 return false;
189}
190
191// letters and change notices from helmd, for the life of this module; reconnects whenever helmd goes away
192function pump($: $, r: Runtime): void {
193 const run = async () => {
194 try {
195 let buf = '';
196 const session = r.session;
197 // a stream reads one session's letters: once the process goes on under another id, it is opened again for that one
198 for await (const piece of $.process.spawn({ argv: daemonArgv(r.install.root, 'stream', session) })) {
199 if (!(await holds($, r)) || r.session !== session) break;
200 if (piece.stream !== 'stdout') continue;
201 buf += piece.text;
202 for (let i = buf.indexOf('\n'); i >= 0; i = buf.indexOf('\n')) {
203 const line = buf.slice(0, i);
204 buf = buf.slice(i + 1);
205 if (line) await frame($, r, JSON.parse(line) as StreamFrame);
206 }
207 }
208 } catch (e) {
209 $.ui.log(`helm: stream ended: ${(e as Error).message}`, { to: 'debug' });
210 }
211 if (!(await holds($, r))) return;
212 r.timers.push(
213 $.clock.after(RECONNECT_MS, () => {
214 void ensureDaemon($, r.client, r.install)
215 .then(() => bind($, r))
216 .catch((e: Error) => $.ui.log(`helm: helmd unavailable: ${e.message}`, { to: 'debug' }))
217 .finally(() => r.alive && pump($, r));
218 }),
219 );
220 };
221 void run();
222}
223
224async function frame($: $, r: Runtime, f: StreamFrame): Promise<void> {
225 if (f.type === 'letter') await r.mailbox.receive(f.letter);
226 if (f.type === 'changed' || f.type === 'hello') refresh($, r);
227}
228
229const selfOf = (r: Runtime): Self => ({ session: r.session, role: r.role, ...(r.repo ? { repo: r.repo } : {}) });
230
231let refreshTimer: { cancel: () => void } | undefined;
232let lastRefresh = 0;
233
234// the fleet for the pane, band and status line, read once per window however many changes land in it
235function refresh($: $, r: Runtime): void {
236 if (refreshTimer || !r.alive) return;
237 const wait = Math.max(0, lastRefresh + REFRESH_MS - Date.now());
238 refreshTimer = $.clock.after(wait, () => {
239 refreshTimer = undefined;
240 lastRefresh = Date.now();
241 void (async () => {
242 const f = await r.client.fleet(true);
243 fleet = f;
244 await update($, fleetAt, () => f.at);
245 $.ui.status(statusText(f, selfOf(r)));
246 })().catch((e: Error) => $.ui.log(`helm: refresh failed: ${e.message}`, { to: 'debug' }));
247 });
248}
249
250// a row of plain segments is one line of text; a row holding a control lays its segments out side by side, each
251// control a Button whose press the ui.press hook routes by its key
252function rowsOf($: $, e: Parameters<$['ui']['resolve']>[0], rows: Row[]) {
253 const { Box, Text, Button } = $.ui.resolve(e);
254 const text = (s: Row[number]) => <Text {...(s.color ? { color: s.color } : {})} dimColor={s.dim ?? false} bold={s.bold ?? false}>{s.text}</Text>;
255 return (
256 <Box flexDirection="column">
257 {rows.map((row) =>
258 row.some((s) => s.press) ? (
259 <Box flexDirection="row">
260 {row.map((s) =>
261 s.press ? (
262 <Button key={s.press} {...(s.boxed ? {} : { plain: true as const })} {...(s.hotkey ? { hotkey: s.hotkey } : {})} dimColor={s.dim ?? false} onPress={() => {}}>
263 {text(s)}
264 </Button>
265 ) : (
266 text(s)
267 ),
268 )}
269 </Box>
270 ) : (
271 <Text wrap="truncate-end">{row.length ? row.map(text) : ' '}</Text>
272 ),
273 )}
274 </Box>
275 );
276}
277
278async function helpText(r: Runtime): Promise<string> {
279 const h = await r.client.health().catch(() => undefined);
280 return [
281 `helm ${r.version} · session ${r.session} · role ${r.role}${r.repo ? ` · ${r.repo}` : ''}`,
282 h ? `helmd ${h.version} pid ${h.pid} · ${h.web ?? ''}` : 'helmd not answering',
283 'commands: /helm pane · /helm role coordinator|repo|other [owner/name] · /helm web · /helm restart',
284 ].join('\n');
285}
286
287export const register: Register = (on) => {
288 const tools: Tool<Port>[] = [...TOOLS, dispatchTool()];
289
290 // outermost on every tool: letters waiting for the calling loop ride the result
291 on('tool.call', async ($, e, next) => {
292 const out = await next(e);
293 const r = rt;
294 if (!r?.alive || out.deny !== undefined) return out;
295 const text = await r.mailbox.attach(e.agentId).catch(() => undefined);
296 return text === undefined ? out : { ...out, context: [...(out.context ?? []), text] };
297 }).catch(($, e, next) => next(e));
298
299 on('session.start', async ($, e, next) => {
300 const started = await next(e);
301 if (rt) {
302 rt.alive = false;
303 rt.timers.forEach((t) => t.cancel());
304 rt.mailbox.stop();
305 }
306 const client = await clientFor($);
307 rolePrompts = { coordinator: await $.fs.read(`${$.plugin.root}/prompts/coordinator.md`), repo: await $.fs.read(`${$.plugin.root}/prompts/repo.md`) };
308 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/package.json`)) as { name: string; version: string };
309 const install: Install = { root: $.plugin.root, name: manifest.name, version: manifest.version, protocol: PROTOCOL };
310 const repo = await sessionRepo($);
311 const env = (await $.env.get('HELM_ROLE')) as SessionRole | undefined;
312 const asked = env && ROLES.includes(env) ? env : undefined;
313 const r: Runtime = {
314 client,
315 version: install.version,
316 install,
317 home: (await $.env.get('HOME')) ?? '',
318 session: await $.session.id(),
319 // until helmd answers
320 role: asked ?? 'other',
321 ...(asked ? { asked } : {}),
322 ...(repo ? { checkout: repo } : {}),
323 instance: crypto.randomUUID(),
324 alive: true,
325 timers: [],
326 ...(repo ? { repo } : {}),
327 mailbox: new Mailbox({
328 now: Date.now,
329 take: (id) => client.take(id),
330 submit: async (text) => (await $.prompt.submit({ text })).drop === undefined,
331 send: async (agent, text) => {
332 const sent = await $.session.send({ to: { agentId: agent }, text });
333 return sent.isDelivered ? undefined : sent.reason;
334 },
335 retire: async (agent) => void (await client.retire(r.session, agent)),
336 status: async (agent) => (await $.agent.list()).find((a) => a.id === agent)?.status as AgentStatus | undefined,
337 after: (ms, fn) => $.clock.after(ms, () => void fn()),
338 log: (line) => $.ui.log(line, { to: 'debug' }),
339 }),
340 };
341 rt = r;
342 await update($, binder, () => r.instance);
343
344 for (const t of tools) await $.tool.register({ name: t.name, description: t.description, inputSchema: t.inputSchema, isDeferred: !t.eager });
345 await $.command.register({ name: PLUGIN, description: 'helm: status, role, web page' });
346 try {
347 await ensureDaemon($, client, install);
348 await bind($, r);
349 r.web = (await client.health()).web;
350 issueTypes.clear();
351 await registerTiers($, client, repo, r.session).catch((err: Error) => $.ui.log(`helm: issue agents not registered: ${err.message}`));
352 } catch (err) {
353 $.ui.log(`helm: ${(err as Error).message}`);
354 }
355 pump($, r);
356 if (r.role !== 'other') void $.ui.open({ id: PANE, title: 'helm' });
357 r.timers.push(
358 $.clock.every(HEARTBEAT_MS, () => {
359 void (async () => {
360 if (!(await holds($, r))) return;
361 await r.client.heartbeat(r.session, await agentRecords($)).catch(async (err: unknown) => {
362 if (err instanceof HelmError && err.status === 404) await bind($, r);
363 });
364 })().catch(() => {});
365 }),
366 );
367 return started;
368 });
369
370 on('session.end', async ($, e, next) => {
371 const out = await next(e);
372 const r = rt;
373 if (!r) return out;
374 if (e.reason === 'clear' || e.reason === 'resume') {
375 // the process goes on under another session id, with no session.start, and this instance keeps the binding; the
376 // new id takes over what the ended one held
377 await update($, binder, () => r.instance).catch(() => {});
378 r.timers.push($.clock.after(500, () => void bind($, r, e.sessionId).catch(() => {})));
379 return out;
380 }
381 r.alive = false;
382 r.timers.forEach((t) => t.cancel());
383 r.mailbox.stop();
384 await r.client.end(e.sessionId).catch(() => {});
385 return out;
386 });
387
388 on('turn.start', ($, e, next) => {
389 rt?.mailbox.turnStarted();
390 return next(e);
391 });
392
393 on('turn.complete', async ($, e, next) => {
394 const out = await next(e);
395 const r = rt;
396 if (r?.alive) {
397 if (e.agentId === undefined) await r.mailbox.turnEnded();
398 else await r.mailbox.agentEnded(e.agentId);
399 }
400 return out;
401 });
402
403 for (const t of tools) {
404 on('tool.call', { tool: `mcp__${PLUGIN}__${t.name}` }, async ($, e) => {
405 const r = rt;
406 if (!r) return { deny: 'helm is starting; call it again in a moment' };
407 if (t.mainOnly && e.agentId !== undefined) return { deny: `helm ${t.name} is the session's own: report to whoever spawned you instead` };
408 try {
409 const text = await t.run(toolEnv(r), e as unknown as Record<string, unknown>, e.agentId, port($));
410 return { result: [{ type: 'text', text }] };
411 } catch (err) {
412 return { deny: `helm ${t.name}: ${(err as Error).message}` };
413 }
414 }).catch(() => ({ deny: `helm ${t.name} failed inside its hook` }));
415 }
416
417 // a repository session and a coordinator each read how helm works in their role, so no instruction file has to
418 on('prompt.compose', async ($, e, next) => {
419 const out = await next(e);
420 const text = rt ? rolePrompt(rt) : undefined;
421 return text ? { sections: [...out.sections, { id: `${PLUGIN}:role`, text, scope: 'session' as const }] } : out;
422 });
423
424 // issue agents start through dispatch, which claims and routes them, so the model never starts one itself. the guard is
425 // on the spawn, not the offer: an offer is also asked when a message resumes an agent, and refusing it strands the agent
426 on('agent.spawn', ($, e, next) =>
427 e.subagentType.startsWith(`${PLUGIN}:issue-`) && next.origin.plugin !== PLUGIN ? { deny: 'issue agents start through mcp__helm__dispatch, which claims and routes the issue' } : next(e),
428 ).catch(($, e, next) => (next.called ? next(e) : { deny: 'the helm spawn guard failed' }));
429
430 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
431 const r = rt;
432 await read($, fleetAt);
433 const tab = await read($, paneTab);
434 const f = fleet;
435 const { Text } = $.ui.resolve(e);
436 if (!r || !f) return <Text dimColor>helm is connecting to helmd</Text>;
437 return rowsOf($, e, paneRows(f, selfOf(r), { rows: Math.max(6, (e.viewport?.rows ?? 24) - 2), cols: e.viewport?.columns ?? 80, tab, ...(r.web ? { web: r.web } : {}) }));
438 });
439
440 // the pane's controls: a tab, an option that answers a decision, an escalation to the person, or a dismissal
441 on('ui.press', async ($, e, next) => {
442 if (e.requestId !== PANE) return next(e);
443 const r = rt;
444 const [kind, id, index] = e.element.split(':');
445 if (kind === 'tab' && isTab(id)) await update($, paneTab, () => id);
446 if (r && id && kind === 'answer') {
447 const option = fleet?.decisions.find((d) => d.id === id)?.options?.[Number(index)];
448 if (option) await r.client.answer(id, { text: '', option, by: 'pane' }).then(() => refresh($, r), (err: Error) => $.ui.log(`helm: answer failed: ${err.message}`, { to: 'debug' }));
449 }
450 if (r && id && kind === 'escalate') await r.client.escalate(id, { by: 'pane' }).then(() => refresh($, r), (err: Error) => $.ui.log(`helm: escalate failed: ${err.message}`, { to: 'debug' }));
451 if (r && id && kind === 'dismiss') await r.client.dismiss(id).then(() => refresh($, r), (err: Error) => $.ui.log(`helm: dismiss failed: ${err.message}`, { to: 'debug' }));
452 return next(e);
453 }).catch(($, e, next) => next(e));
454
455 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
456 const r = rt;
457 await read($, fleetAt);
458 const f = fleet;
459 const band = r && f && !e.props.hasSurvey ? bandRow(f, selfOf(r)) : undefined;
460 if (!band) return next(e);
461 const { Box } = $.ui.resolve(e);
462 return (
463 <Box flexDirection="column">
464 {await next(e)}
465 {rowsOf($, e, [band])}
466 </Box>
467 );
468 });
469
470 on('command.run', { command: PLUGIN }, async ($, e) => {
471 const r = rt;
472 if (!r) return { text: 'helm is starting' };
473 const [head = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean);
474 try {
475 if (head === 'role') {
476 const role = rest[0] as SessionRole;
477 if (!ROLES.includes(role)) return { text: 'usage: /helm role coordinator|repo|other [owner/name]' };
478 const repo = rest[1] && isRepoName(rest[1]) ? rest[1] : r.repo;
479 const s = await r.client.role(r.session, role, repo);
480 r.asked = s.role;
481 r.role = s.role;
482 if (s.repo) r.repo = s.repo;
483 return { text: `this session is now ${s.role}${s.repo && s.role === 'repo' ? ` for ${s.repo}` : ''}` };
484 }
485 if (head === 'pane') {
486 const opened = await $.ui.open({ id: PANE, title: 'helm', focus: true });
487 refresh($, r);
488 return { text: opened.isPlaced ? 'helm pane open' : `helm pane waits: ${opened.reason}` };
489 }
490 if (head === 'web') {
491 const url = (await r.client.health()).web ?? '';
492 await $.process.run(['xdg-open', url]).catch(() => undefined);
493 return { text: url };
494 }
495 if (head === 'restart') {
496 const res = await $.process.run(daemonArgv((await daemonInstall($, r.install)).root, 'restart'), { timeoutMs: 30_000 });
497 if (res.exitCode !== 0) throw new Error(`helmd restart failed: ${(res.stderr || res.stdout).trim()}`);
498 return { text: 'helmd restarted' };
499 }
500 return { text: await helpText(r) };
501 } catch (err) {
502 return { text: `helm: ${(err as Error).message}` };
503 }
504 });
505};
506src/core/paths.ts 43 lines1export type PathEnv = {
2 HOME: string;
3 XDG_RUNTIME_DIR?: string;
4 XDG_STATE_HOME?: string;
5 XDG_CONFIG_HOME?: string;
6 XDG_CACHE_HOME?: string;
7 HELM_SOCKET?: string;
8 HELM_HOME?: string;
9};
10
11export type HelmPaths = {
12 socket: string;
13 state: string;
14 config: string;
15 cache: string;
16 ledger: string;
17 repos: string;
18 logs: string;
19 lock: string;
20 daemonLog: string;
21};
22
23// where helmd keeps everything, resolved the same way by the daemon and by every session's mod. HELM_HOME puts it
24// all under one directory, for tests and for running a second daemon beside the real one
25export function helmPaths(env: PathEnv): HelmPaths {
26 const root = env.HELM_HOME;
27 const state = root ? `${root}/state` : `${env.XDG_STATE_HOME || `${env.HOME}/.local/state`}/helm`;
28 const config = root ? `${root}/config` : `${env.XDG_CONFIG_HOME || `${env.HOME}/.config`}/helm`;
29 const cache = root ? `${root}/cache` : `${env.XDG_CACHE_HOME || `${env.HOME}/.cache`}/helm`;
30 const runtime = root ? `${root}/run` : env.XDG_RUNTIME_DIR ? `${env.XDG_RUNTIME_DIR}/helm` : `${state}/run`;
31 return {
32 socket: env.HELM_SOCKET || `${runtime}/helmd.sock`,
33 state,
34 config,
35 cache,
36 ledger: `${state}/ledger.json`,
37 repos: `${state}/repos`,
38 logs: `${cache}/logs`,
39 lock: `${runtime}/helmd.lock`,
40 daemonLog: `${state}/helmd.log`,
41 };
42}
43src/core/protocol.ts 121 lines1import { compareVersions } from './repo.ts';
2import type { AgentRecord, CiFilter, Decision, DecisionKind, Effort, Letter, PlanStep, ReportState, RepoName, Routing, Scope, SessionRole, Tier, Until } from './types.ts';
3
4// bumped when a route or a body changes shape; a mod that finds an older daemon replaces it
5export const PROTOCOL = 3;
6
7export type Health = { version: string; protocol: number; pid: number; startedAt: number; web?: string };
8
9// what a mod does about the helmd it finds: start one, replace an older one, use one as new or newer, or, when that one
10// speaks a newer protocol, wait for this session to reload onto a mod that speaks it. a newer helmd is never replaced
11export function daemonAction(up: Pick<Health, 'version' | 'protocol'> | undefined, version: string, protocol = PROTOCOL): 'start' | 'restart' | 'use' | 'reload' {
12 if (!up) return 'start';
13 if (up.protocol > protocol) return 'reload';
14 if (up.protocol < protocol || compareVersions(up.version, version) < 0) return 'restart';
15 return 'use';
16}
17
18// from: the session this one goes on from in the same process, as a /clear or a resume ended it. role is the role the
19// session was asked to take; repo is its checkout's, a default for a session helmd does not know. protocol is the mod's:
20// before 3 a mod sent the role it guessed, which only defaults a new session
21export type RegisterBody = { id: string; cwd: string; role?: SessionRole; repo?: RepoName; title?: string; from?: string; protocol?: number };
22
23export type HeartbeatBody = { agents: AgentRecord[] };
24
25export type RoleBody = { role: SessionRole; repo?: RepoName };
26
27export type SubscribeBody = {
28 repo?: RepoName;
29 scope: Scope;
30 ci?: CiFilter;
31 tags?: string[];
32 bots?: boolean;
33 until?: Until;
34 // a pr subscription's head as the caller pushed it: ci on a head strictly behind it is not delivered
35 sha?: string;
36 session: string;
37 agent?: string;
38};
39
40export type QueueBody = { session: string; repo: RepoName; issues: number[] };
41
42// title: what the caller read of the issue, for when the forge cache has not seen it yet
43export type ClaimBody = { session: string; repo: RepoName; issue: number; title?: string; agent?: string; routing?: Routing; force?: boolean };
44
45export type ReportBody = {
46 session: string;
47 agent?: string;
48 repo: RepoName;
49 issue: number;
50 state: ReportState;
51 note?: string;
52 // a question that stops the agent until answered
53 question?: { title: string; body: string; options?: string[] };
54 // choices made without the person, recorded for review
55 choices?: { title: string; body: string }[];
56 // the plan as it stands, every step with whether it is done
57 plan?: PlanStep[];
58};
59
60export type ReleaseBody = { session: string; repo: RepoName; issue: number; how?: 'abandoned' };
61
62export type OrderBody = { session: string; keys: string[] };
63
64export type DecisionBody = {
65 kind: DecisionKind;
66 repo?: RepoName;
67 issue?: number;
68 title: string;
69 body: string;
70 options?: string[];
71 blocking: boolean;
72 from?: { session: string; agent?: string };
73};
74
75export type AnswerBody = { text: string; option?: string; by: string };
76
77export type EscalateBody = { by: string; note?: string };
78
79export type RoutingConfig = { judge: string; review: number; tiers: Tier[]; fallback: string };
80
81export type ConfigView = { routing: RoutingConfig; web?: string };
82
83// what a session's stream carries, one json object a line
84export type StreamFrame =
85 | { type: 'hello'; version: string; protocol: number }
86 | { type: 'letter'; letter: Letter }
87 | { type: 'changed'; at: number }
88 | { type: 'ping'; at: number };
89
90export type IssueDetail = {
91 repo: RepoName;
92 number: number;
93 title: string;
94 state: string;
95 url: string;
96 pr: boolean;
97 author: string;
98 labels: string[];
99 body: string;
100 comments: { author: string; at: string; url: string; body: string }[];
101};
102
103export type Problem = { error: string; detail?: unknown };
104
105export type ClaimRefused = Problem & { owner: string };
106
107export type Answered = { decision: Decision; delivered: boolean };
108
109export function workKey(repo: RepoName, issue: number): string {
110 return `${repo}#${issue}`;
111}
112
113export function parseWorkKey(key: string): { repo: RepoName; issue: number } | undefined {
114 const m = /^([^/\s#]+\/[^/\s#]+)#(\d+)$/.exec(key);
115 return m ? { repo: m[1]!, issue: Number(m[2]) } : undefined;
116}
117
118export function isEffort(v: unknown): v is Effort {
119 return v === 'low' || v === 'medium' || v === 'high' || v === 'xhigh' || v === 'max';
120}
121src/core/repo.ts 20 lines1import type { RepoName } from './types.ts';
2
3// owner/name of a github remote url in any of its spellings
4export function repoOfRemote(url: string): RepoName | undefined {
5 const m = /github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url.trim());
6 return m ? `${m[1]}/${m[2]}` : undefined;
7}
8
9export function isRepoName(v: unknown): v is RepoName {
10 return typeof v === 'string' && /^[\w.-]+\/[\w.-]+$/.test(v);
11}
12
13// a strict semver comparison of x.y.z, prerelease ignored; negative when a is older
14export function compareVersions(a: string, b: string): number {
15 const pa = a.split(/[.-]/).slice(0, 3).map(Number);
16 const pb = b.split(/[.-]/).slice(0, 3).map(Number);
17 for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
18 return 0;
19}
20src/core/types.ts 445 lines1// the contract between helmd, the mod and the web page. plain json data only: everything here crosses a socket
2
3export type RepoName = string;
4
5export type Author = { login: string; bot: boolean };
6
7export type Note = { author: Author; at: string; url: string; text: string };
8
9export type IssueRef = { repo: RepoName; number: number };
10
11// one sub-issue as its parent lists it; its repository may be one helm does not poll
12export type Child = IssueRef & { title: string; url: string; state: 'open' | 'closed'; subIssues?: { total: number; done: number } };
13
14export type Issue = {
15 number: number;
16 title: string;
17 url: string;
18 state: 'open' | 'closed';
19 // why it closed, as the forge says it (completed, not_planned, duplicate)
20 reason?: string;
21 author: Author;
22 labels: string[];
23 assignees: string[];
24 parent?: IssueRef;
25 subIssues?: { total: number; done: number };
26 // open issues it is blocked by, as github's issue dependencies record them; read for open issues only
27 blockedBy?: number;
28 comments: number;
29 lastComment?: Note;
30 createdAt: string;
31 updatedAt: string;
32 closedAt?: string;
33};
34
35export type CheckState = 'queued' | 'running' | 'success' | 'failure' | 'neutral' | 'skipped' | 'cancelled';
36
37// one check on a commit: a check run (with its actions job and run when it has one) or a commit status
38export type Check = {
39 name: string;
40 state: CheckState;
41 url?: string;
42 job?: number;
43 run?: number;
44 workflow?: string;
45 startedAt?: string;
46 completedAt?: string;
47};
48
49export type Verdict = 'pending' | 'success' | 'failure' | 'none';
50
51export type Pull = {
52 number: number;
53 title: string;
54 url: string;
55 state: 'open' | 'closed' | 'merged';
56 draft: boolean;
57 author: Author;
58 head: string;
59 sha: string;
60 // the head branch lives in a fork, so this machine's origin/<head> is not it
61 fork?: boolean;
62 base: string;
63 closes: number[];
64 review?: string;
65 mergeable?: string;
66 comments: number;
67 lastComment?: Note;
68 reviews: number;
69 lastReview?: Note & { state: string };
70 checks: Check[];
71 createdAt: string;
72 updatedAt: string;
73 closedAt?: string;
74};
75
76export type Step = { number: number; name: string; state: CheckState };
77
78export type Job = {
79 id: number;
80 run: number;
81 name: string;
82 state: CheckState;
83 url: string;
84 steps: Step[];
85 startedAt?: string;
86 completedAt?: string;
87};
88
89export type Run = {
90 id: number;
91 workflow: string;
92 branch: string;
93 sha: string;
94 event: string;
95 state: CheckState;
96 url: string;
97 actor: string;
98 // ref is a tag, not a branch
99 tag?: boolean;
100 createdAt: string;
101 updatedAt: string;
102 // jobs are read only while the run is in flight and once when it completes
103 jobs?: Job[];
104};
105
106// what the forge holds for one repository, as of the last poll
107export type ForgeState = {
108 repo: RepoName;
109 defaultBranch: string;
110 issues: Issue[];
111 pulls: Pull[];
112 runs: Run[];
113 // issue number -> its sub-issues, for every issue here that has any
114 children: Record<number, Child[]>;
115 polledAt: number;
116};
117
118export type Worktree = {
119 path: string;
120 branch?: string;
121 sha: string;
122 main: boolean;
123 dirty: number;
124 ahead?: number;
125 behind?: number;
126 upstream?: string;
127 locked?: boolean;
128};
129
130export type Branch = {
131 name: string;
132 sha: string;
133 upstream?: string;
134 ahead?: number;
135 behind?: number;
136 gone?: boolean;
137 committedAt: number;
138};
139
140// what the machine holds for one repository: its checkouts, their worktrees and the local branches
141export type LocalState = {
142 repo: RepoName;
143 checkouts: string[];
144 worktrees: Worktree[];
145 branches: Branch[];
146 scannedAt: number;
147};
148
149export type SessionRole = 'coordinator' | 'repo' | 'other';
150
151// the engine's agent statuses, and gone for one no live session lists
152export type AgentStatus = 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed' | 'gone';
153
154export type AgentRecord = {
155 id: string;
156 name?: string;
157 type: string;
158 description: string;
159 status: AgentStatus;
160 parentId?: string;
161};
162
163export type Session = {
164 id: string;
165 role: SessionRole;
166 repo?: RepoName;
167 cwd: string;
168 title?: string;
169 agents: AgentRecord[];
170 startedAt: number;
171 seenAt: number;
172 // set once a heartbeat is overdue; a session that comes back clears it
173 gone?: boolean;
174 // what this session took over by succession and has not given back, oldest first
175 adoptions?: AdoptionRecord[];
176};
177
178// one succession: what moved to the adopting session and what each thing was before, so it can be given back exactly.
179// a work item is still the adoption's while its owner is unchanged, its agent is the one it came with and nobody has
180// reported on it since
181export type AdoptionRecord = {
182 at: number;
183 repo: RepoName;
184 work: { key: string; agent?: string; was: Pick<Work, 'owner' | 'order' | 'report' | 'adopted'> }[];
185 subscriptions: { id: string; agent?: string; was: string }[];
186 decisions: { id: string; was: Pick<Decision, 'to' | 'session' | 'escalated'> }[];
187};
188
189export type Tier = {
190 name: string;
191 model: string;
192 effort: Effort;
193 // when an issue belongs on this tier, read by the routing judge
194 when: string;
195};
196
197export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
198
199export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max'];
200
201export type Routing = {
202 tier?: string;
203 model: string;
204 effort: Effort;
205 by: 'judge' | 'caller' | 'human';
206 confidence?: number;
207 reason?: string;
208 at: number;
209};
210
211// what an agent says about its own work: the states only it can know
212export type ReportState = 'working' | 'waiting' | 'blocked' | 'ready' | 'stopped' | 'abandoned';
213
214export type Report = { state: ReportState; note?: string; at: number };
215
216export type PlanStep = { text: string; done: boolean };
217
218// a live process an ended agent left in its worktree; helm reports it and never kills it
219export type Leftover = { pid: number; command: string };
220
221// one issue in the ledger: queued in a session's backlog, then worked by one agent at a time
222export type Work = {
223 repo: RepoName;
224 issue: number;
225 title: string;
226 owner?: string;
227 order: number;
228 agent?: string;
229 routing?: Routing;
230 report?: Report;
231 // the agent's plan as it last reported it
232 plan?: PlanStep[];
233 // what was still running in its worktree when its agent last ended
234 leftovers?: Leftover[];
235 // when the work entered each phase, oldest first
236 history?: { phase: Phase; at: number }[];
237 queuedAt: number;
238 claimedAt?: number;
239 updatedAt: number;
240 finished?: { at: number; how: 'merged' | 'closed' | 'abandoned' };
241 // taken over from a gone session; agent is the one it had then, gone with that session and so never resumed
242 adopted?: { from: string; at: number; agent?: string };
243};
244
245// parked: set aside on purpose, by a stopped report or, with nobody on it, a blocked or parked label or an open blocked-by
246export type Phase = 'queued' | 'working' | 'draft' | 'ci' | 'failing' | 'ready' | 'blocked' | 'stalled' | 'parked' | 'done';
247
248// a work item joined with what the forge, the machine and the ledger say about it now
249export type WorkView = Work & {
250 phase: Phase;
251 agentStatus?: AgentStatus;
252 pull?: Pick<Pull, 'number' | 'url' | 'draft' | 'state' | 'head' | 'sha'>;
253 verdict: Verdict;
254 checks: Check[];
255 jobs: Job[];
256 worktree?: Worktree;
257 decisions: number;
258 issueState?: 'open' | 'closed';
259 issueUrl?: string;
260};
261
262// what a subtree of the hierarchy adds up to, counted over its leaves
263export type Rollup = {
264 total: number;
265 done: number;
266 // worked by an agent: working, draft, ci or ready
267 active: number;
268 // blocked, failing or stalled
269 attention: number;
270 ci: number;
271 // ready to merge, also counted active
272 ready: number;
273 queued: number;
274 // set aside on purpose; neither active nor attention
275 parked: number;
276 // open with nobody on it
277 unowned: number;
278};
279
280export type TreeNode = IssueRef & {
281 title: string;
282 url: string;
283 state: 'open' | 'closed';
284 // the work item's phase when the ledger has one, done when closed, absent when open and unowned
285 phase?: Phase;
286 owner?: string;
287 agent?: string;
288 tier?: string;
289 plan?: { done: number; total: number };
290 // a sub-epic in a repository helm does not poll: counted from its summary, its children unknown
291 external?: boolean;
292 children: TreeNode[];
293 rollup: Rollup;
294};
295
296export type DecisionKind = 'routing' | 'question' | 'choice' | 'stall' | 'failure';
297
298export type Answer = { text: string; option?: string; by: string; at: number };
299
300// who a decision is for: the person, or the session that owns the work it concerns
301export type Audience = 'person' | 'session';
302
303export type Decision = {
304 id: string;
305 kind: DecisionKind;
306 repo?: RepoName;
307 issue?: number;
308 title: string;
309 body: string;
310 options?: string[];
311 // true when someone is stopped until it is answered
312 blocking: boolean;
313 from?: { session: string; agent?: string };
314 to: Audience;
315 // the session it is addressed to, set exactly when to is session
316 session?: string;
317 // handed on to the person: by the session it was addressed to, or by helm once that session is gone
318 escalated?: { by: string; note?: string; at: number };
319 state: 'open' | 'answered' | 'dismissed' | 'resolved';
320 answer?: Answer;
321 // a condition helmd raised and clears itself once it no longer holds
322 key?: string;
323 // what held when it was raised; a dismissal holds against it until the condition changes or ends
324 condition?: string;
325 createdAt: number;
326 updatedAt: number;
327};
328
329export type Scope =
330 | { kind: 'repo' }
331 | { kind: 'issue'; number: number }
332 | { kind: 'pr'; number: number }
333 | { kind: 'branch'; name: string }
334 | { kind: 'run'; id: number }
335 | { kind: 'tag'; glob: string }
336 // the work items the subscriber's session owns, across repositories
337 | { kind: 'work' }
338 // every work item, decision and session, across the machine
339 | { kind: 'fleet' };
340
341export type CiFilter = 'settled' | 'failures' | 'all' | 'none';
342
343export type Until = 'settled' | 'merged' | 'closed' | { at: string };
344
345export type Subscription = {
346 id: string;
347 // absent for the work and fleet scopes, which span repositories
348 repo?: RepoName;
349 scope: Scope;
350 ci: CiFilter;
351 // the item tags delivered; absent takes the defaults
352 tags?: string[];
353 bots: boolean;
354 until?: Until;
355 // a pr subscription's expected head. named by the caller, ci on a head that is neither it nor past it is not its
356 // verdict; guessed from the local checkout, which can itself be stale, only ci on a head strictly behind it is not
357 head?: string;
358 named?: boolean;
359 // the pr head whose settled ci this subscription held back, seen again by a later poll once that head stays: the
360 // verdict it waits on can then no longer come, and the wait no longer counts as work going on
361 held?: { sha: string; seen?: true };
362 session: string;
363 agent?: string;
364 createdAt: number;
365};
366
367export type EventKind = 'issue' | 'pr' | 'ci' | 'work' | 'decision' | 'epic';
368
369export type HelmEvent = {
370 id: string;
371 kind: EventKind;
372 repo?: RepoName;
373 at: number;
374 // what the event is about, matched against scopes
375 issue?: number;
376 pr?: number;
377 branch?: string;
378 run?: number;
379 sha?: string;
380 tag?: string;
381 // tags a filter reads: opened, closed, merged, comment, review, ready, draft, edited, labeled, settled, stalled,
382 // completed, success, failure, phase, decision, answered, progress, complete, leftovers
383 tags: string[];
384 author?: Author;
385 // one line, then detail lines
386 text: string;
387 detail?: string[];
388 url?: string;
389 // the session whose work it is, for the work scope
390 owner?: string;
391 // the root epic a work event rolls up into, whose progress event speaks for it on the fleet scope
392 epic?: string;
393 // a work event's phase change, which a later one of the same item supersedes
394 phase?: { from: Phase | 'new'; to: Phase; title: string };
395};
396
397// one event of a letter: the group it is listed under, [helm <head>], absent for a letter of its own words; its
398// lines; and for a phase change, the item and the phases it went through, which a newer part of the item extends
399export type LetterPart = {
400 head?: string;
401 lines: string[];
402 phase?: { key: string; path: string[]; title: string };
403};
404
405// one delivery to a session, or to one agent of it
406export type Letter = {
407 id: string;
408 session: string;
409 agent?: string;
410 // the parts rendered whole, what a reader without the parts shows
411 text: string;
412 // absent on a letter posted before letters carried parts
413 parts?: LetterPart[];
414 events: string[];
415 subs: string[];
416 at: number;
417};
418
419// a lite view carries only polling and the runs in flight or just finished, what a pane draws its ci from
420export type RepoView = { forge?: ForgeState; local?: LocalState; polling: PollStatus; runs?: Run[] };
421
422export type PollStatus = {
423 active: boolean;
424 interval: number;
425 lastPoll?: number;
426 lastChange?: number;
427 error?: string;
428 failures: number;
429};
430
431export type Fleet = {
432 version: string;
433 sessions: Session[];
434 work: WorkView[];
435 decisions: Decision[];
436 subscriptions: Subscription[];
437 repos: Record<RepoName, RepoView>;
438 // every epic in the watched repositories that no other epic here holds, with its subtree
439 tree: TreeNode[];
440 // the newest events, newest last; absent from a lite answer
441 events?: HelmEvent[];
442 rates: Record<string, { remaining: number; limit: number; resetAt: number }>;
443 at: number;
444};
445src/mod/client.ts 111 lines1import type {
2 AnswerBody,
3 Answered,
4 ClaimBody,
5 ConfigView,
6 DecisionBody,
7 EscalateBody,
8 Health,
9 IssueDetail,
10 OrderBody,
11 QueueBody,
12 RegisterBody,
13 ReleaseBody,
14 ReportBody,
15 SubscribeBody,
16} from '../core/protocol.ts';
17import type { AgentRecord, Decision, Fleet, Letter, RepoName, RepoView, Session, SessionRole, Subscription, Work } from '../core/types.ts';
18
19export type HttpLike = (url: string, init: { method?: string; headers?: Record<string, string>; body?: string; socketPath: string }) => Promise<{ status: number; ok: boolean; text: string }>;
20
21export class HelmError extends Error {
22 status: number;
23 detail?: unknown;
24 constructor(status: number, message: string, detail?: unknown) {
25 super(message);
26 this.status = status;
27 this.detail = detail;
28 }
29}
30
31const enc = encodeURIComponent;
32
33// helmd's socket api as typed calls. every failure is a HelmError with the daemon's own words
34export class HelmClient {
35 readonly socket: string;
36 private readonly http: HttpLike;
37
38 constructor(http: HttpLike, socket: string) {
39 this.http = http;
40 this.socket = socket;
41 }
42
43 async call<T>(method: string, path: string, body?: unknown): Promise<T> {
44 let res;
45 try {
46 res = await this.http(`http://helmd${path}`, {
47 method,
48 socketPath: this.socket,
49 ...(body === undefined ? {} : { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }),
50 });
51 } catch (e) {
52 throw new HelmError(0, `helmd unreachable on ${this.socket}: ${(e as Error).message}`);
53 }
54 const parsed = res.text ? safeJson(res.text) : null;
55 if (!res.ok) {
56 const p = parsed as { error?: string; detail?: unknown } | null;
57 throw new HelmError(res.status, p?.error ?? `http ${res.status}`, p?.detail);
58 }
59 return (parsed ?? res.text) as T;
60 }
61
62 async text(path: string): Promise<string> {
63 const res = await this.http(`http://helmd${path}`, { socketPath: this.socket }).catch((e: Error) => {
64 throw new HelmError(0, `helmd unreachable: ${e.message}`);
65 });
66 if (!res.ok) throw new HelmError(res.status, (safeJson(res.text) as { error?: string } | null)?.error ?? `http ${res.status}`);
67 return res.text;
68 }
69
70 health = () => this.call<Health>('GET', '/v1/health');
71 shutdown = () => this.call<unknown>('POST', '/v1/shutdown');
72 fleet = (lite = false) => this.call<Fleet>('GET', `/v1/fleet${lite ? '?lite=1' : ''}`);
73 config = (repo?: RepoName) => this.call<ConfigView>('GET', `/v1/config${repo ? `?repo=${enc(repo)}` : ''}`);
74 register = (b: RegisterBody) => this.call<Session>('POST', '/v1/sessions', b);
75 heartbeat = (id: string, agents: AgentRecord[]) => this.call<Session>('POST', `/v1/sessions/${enc(id)}/heartbeat`, { agents });
76 role = (id: string, role: SessionRole, repo?: RepoName) => this.call<Session>('POST', `/v1/sessions/${enc(id)}/role`, { role, ...(repo ? { repo } : {}) });
77 end = (id: string) => this.call<unknown>('POST', `/v1/sessions/${enc(id)}/end`);
78 letters = (session: string, agent?: string) => this.call<Letter[]>('GET', `/v1/letters?session=${enc(session)}${agent ? `&agent=${enc(agent)}` : ''}`);
79 // undefined once another taker had it
80 take = (id: string) =>
81 this.call<Letter>('POST', `/v1/letters/${enc(id)}/take`).catch((e: unknown) => {
82 if (e instanceof HelmError && e.status === 404) return undefined;
83 throw e;
84 });
85 subscribe = (b: SubscribeBody) => this.call<Subscription>('POST', '/v1/subscriptions', b);
86 unsubscribe = (id: string) => this.call<{ removed: boolean }>('DELETE', `/v1/subscriptions/${enc(id)}`);
87 retire = (session: string, agent: string) => this.call<{ retired: string[] }>('POST', '/v1/subscriptions/retire', { session, agent });
88 queue = (b: QueueBody) => this.call<Work[]>('POST', '/v1/work/queue', b);
89 claim = (b: ClaimBody) => this.call<Work>('POST', '/v1/work/claim', b);
90 report = (b: ReportBody) => this.call<{ work: Work; decisions: Decision[] }>('POST', '/v1/work/report', b);
91 release = (b: ReleaseBody) => this.call<Work | null>('POST', '/v1/work/release', b);
92 order = (b: OrderBody) => this.call<{ ordered: number }>('POST', '/v1/work/order', b);
93 decide = (b: DecisionBody) => this.call<Decision>('POST', '/v1/decisions', b);
94 answer = (id: string, b: AnswerBody) => this.call<Answered>('POST', `/v1/decisions/${enc(id)}/answer`, b);
95 escalate = (id: string, b: EscalateBody) => this.call<Decision>('POST', `/v1/decisions/${enc(id)}/escalate`, b);
96 dismiss = (id: string) => this.call<Decision>('POST', `/v1/decisions/${enc(id)}/dismiss`);
97 repo = (repo: RepoName) => this.call<RepoView>('GET', `/v1/repos/${repo}`);
98 poll = (repo: RepoName) => this.call<RepoView>('POST', `/v1/repos/${repo}/poll`);
99 issue = (repo: RepoName, n: number) => this.call<IssueDetail>('GET', `/v1/repos/${repo}/issues/${n}`);
100 log = (repo: RepoName, job: number, q: { tail?: number; grep?: string; errors?: boolean }) =>
101 this.text(`/v1/repos/${repo}/jobs/${job}/log?${[q.tail ? `tail=${q.tail}` : '', q.grep ? `grep=${enc(q.grep)}` : '', q.errors ? 'errors=1' : ''].filter(Boolean).join('&')}`);
102}
103
104function safeJson(text: string): unknown {
105 try {
106 return JSON.parse(text);
107 } catch {
108 return text;
109 }
110}
111src/mod/install.ts 40 lines1import { compareVersions } from '../core/repo.ts';
2
3// one copy of helm on the machine: where it is, its version, and the protocol its helmd speaks
4export type Install = { root: string; name: string; version: string; protocol: number };
5
6export type InstallFs = { dirs: (path: string) => Promise<string[]>; read: (path: string) => Promise<string> };
7
8type Manifest = { name?: unknown; version?: unknown; helm?: { protocol?: unknown } };
9
10// what a package.json says of the install at root, when it declares the protocol its helmd speaks
11export function installOf(root: string, text: string): Install | undefined {
12 let m: Manifest;
13 try {
14 m = JSON.parse(text) as Manifest;
15 } catch {
16 return undefined;
17 }
18 const protocol = m.helm?.protocol;
19 if (typeof m.name !== 'string' || typeof m.version !== 'string' || typeof protocol !== 'number') return undefined;
20 return { root, name: m.name, version: m.version, protocol };
21}
22
23// the helmd a mod starts: the newest install beside its own that speaks its protocol. the plugin cache keeps every
24// installed version side by side, each in a directory named for it; a root laid out otherwise (a dev checkout,
25// --plugin-dir) has only itself
26export async function newestInstall(own: Install, fs: InstallFs): Promise<Install> {
27 const root = own.root.replace(/\/+$/, '');
28 const cut = root.lastIndexOf('/');
29 if (cut <= 0 || root.slice(cut + 1) !== own.version) return own;
30 const parent = root.slice(0, cut);
31 let best = own;
32 for (const name of await fs.dirs(parent).catch(() => [])) {
33 if (!/^\d+\.\d+\.\d+/.test(name) || compareVersions(name, best.version) <= 0) continue;
34 const text = await fs.read(`${parent}/${name}/package.json`).catch(() => undefined);
35 const it = text === undefined ? undefined : installOf(`${parent}/${name}`, text);
36 if (it && it.name === own.name && it.version === name && it.protocol === own.protocol) best = it;
37 }
38 return best;
39}
40src/mod/dispatch.ts 110 lines1import { isRepoName } from '../core/repo.ts';
2import { workKey } from '../core/protocol.ts';
3import type { Effort, RepoName, Routing } from '../core/types.ts';
4import { HelmError } from './client.ts';
5import { asRouting, parseRouting, routingPrompt } from './routing.ts';
6import type { Tool, ToolEnv } from './tools.ts';
7
8// what dispatch needs of the engine, built by the hooks module
9export type DispatchPort = {
10 // the issue agent type for one model at one effort, registered on first use
11 agentType: (model: string, effort: Effort) => Promise<string>;
12 spawn: (a: { subagentType: string; description: string; prompt: string }) => Promise<{ agentId?: string; deny?: string }>;
13 complete: (a: { model: string; prompt: string }) => Promise<{ text: string } | { failed: string }>;
14 now: () => number;
15};
16
17export type DispatchInput = { repo?: RepoName; issues: number[]; tier?: string; context?: string };
18
19// the agent type an issue runs as: a spawn takes neither a full model id nor an effort, an agent type takes both, so
20// each model and effort a tier names is a type of its own
21export function issueAgentName(model: string, effort: Effort): string {
22 return `issue-${model.replace(/[^\w-]/g, '-')}-${effort}`.slice(0, 64);
23}
24
25export async function dispatchIssues(env: ToolEnv, port: DispatchPort, input: DispatchInput): Promise<string> {
26 const repo = env.repo(input.repo);
27 const cfg = (await env.client.config(repo)).routing;
28 const named = input.tier ? cfg.tiers.find((t) => t.name === input.tier) : undefined;
29 if (input.tier && !named) throw new HelmError(400, `no tier ${input.tier}: ${cfg.tiers.map((t) => t.name).join(', ')}`);
30 const lines: string[] = [];
31 for (const issue of input.issues) {
32 const key = workKey(repo, issue);
33 try {
34 const detail = await env.client.issue(repo, issue);
35 if (detail.pr || detail.state !== 'open') {
36 lines.push(`${key}: ${detail.pr ? 'a pull request' : detail.state}, not dispatched`);
37 continue;
38 }
39 await env.client.claim({ session: env.session(), repo, issue, title: detail.title });
40 let routing: Routing;
41 if (named) routing = asRouting(named, 'caller', port.now());
42 else {
43 const work = (await env.client.fleet(true)).work.find((w) => w.repo === repo && w.issue === issue);
44 const answer = await port.complete({ model: cfg.judge, prompt: routingPrompt(detail, cfg.tiers, work) });
45 const fallback = cfg.tiers.find((t) => t.name === cfg.fallback)!;
46 routing = 'text' in answer ? parseRouting(answer.text, cfg, port.now()) : { ...asRouting(fallback, 'judge', port.now()), confidence: 0, reason: `the judge did not answer (${answer.failed}); fell back to ${fallback.name}` };
47 }
48 const spawned = await port.spawn({
49 subagentType: await port.agentType(routing.model, routing.effort),
50 description: `#${issue} ${detail.title}`.slice(0, 60),
51 prompt: [`Issue ${key}: ${detail.title}`, detail.url, ...(input.context ? ['', input.context] : [])].join('\n'),
52 });
53 if (spawned.deny !== undefined || !spawned.agentId) {
54 lines.push(`${key}: the agent did not start: ${spawned.deny ?? 'no agent id'}; it stays queued`);
55 continue;
56 }
57 await env.client.claim({ session: env.session(), repo, issue, title: detail.title, agent: spawned.agentId, routing });
58 const low = routing.by === 'judge' && (routing.confidence ?? 0) < cfg.review;
59 await env.client.decide({
60 kind: 'routing',
61 repo,
62 issue,
63 title: `#${issue} routed to ${routing.tier} (${routing.model}/${routing.effort})${low ? ', low confidence' : ''}`,
64 body: [detail.title, routing.by === 'caller' ? 'tier named by the session' : `judge ${cfg.judge}, confidence ${(routing.confidence ?? 0).toFixed(2)}: ${routing.reason ?? ''}`, 'Answer with another tier to reroute.'].join('\n'),
65 options: cfg.tiers.map((t) => t.name),
66 blocking: false,
67 from: { session: env.session() },
68 });
69 lines.push(`${key} → ${routing.tier} ${routing.model}/${routing.effort}, agent ${spawned.agentId}${routing.reason ? `: ${routing.reason}` : ''}`);
70 } catch (e) {
71 lines.push(`${key}: ${(e as Error).message}`);
72 }
73 }
74 return lines.join('\n');
75}
76
77const ints = (v: unknown): number[] => (Array.isArray(v) ? v : [v]).map((x) => (typeof x === 'string' ? Number(x.replace('#', '')) : x)).filter((n): n is number => typeof n === 'number' && Number.isInteger(n) && n > 0);
78
79export function dispatchTool(): Tool<DispatchPort> {
80 return {
81 name: 'dispatch',
82 eager: true,
83 mainOnly: true,
84 description:
85 'Start an issue agent on each issue, the only way to start one. Claims the issue for this session, routes it to a tier of model and effort (the routing judge reads the issue unless you name a tier, and every pick is logged for the person to review or override), and spawns the agent in the background. The agent resumes an existing branch and PR when there is one. Its progress arrives as work deliveries, and its final report arrives when it ends.',
86 inputSchema: {
87 type: 'object',
88 properties: {
89 issues: { type: 'array', items: { type: 'number' } },
90 repo: { type: 'string', description: 'owner/name; defaults to the session repository' },
91 tier: { type: 'string', description: 'a routing tier by name, skipping the judge' },
92 context: { type: 'string', description: 'what the agent should know beyond the issue: an answer to its question, a constraint, where to resume' },
93 },
94 required: ['issues'],
95 },
96 async run(env, input, _agent, port) {
97 const issues = ints(input.issues);
98 if (!issues.length) throw new HelmError(400, 'issues is required');
99 const repo = typeof input.repo === 'string' && input.repo ? input.repo : undefined;
100 if (repo !== undefined && !isRepoName(repo)) throw new HelmError(400, `repo ${repo} is not owner/name`);
101 return dispatchIssues(env, port, {
102 issues,
103 ...(repo ? { repo } : {}),
104 ...(typeof input.tier === 'string' && input.tier ? { tier: input.tier } : {}),
105 ...(typeof input.context === 'string' && input.context ? { context: input.context } : {}),
106 });
107 },
108 };
109}
110src/mod/mailbox.ts 156 lines1import { deliveryText } from '../core/letter.ts';
2import type { AgentStatus, Letter } from '../core/types.ts';
3
4export type Timer = { cancel: () => void };
5
6export type MailHost = {
7 now: () => number;
8 // claims a letter from helmd; undefined when another environment or session took it first
9 take: (id: string) => Promise<Letter | undefined>;
10 // a turn of the main loop's own; false when a hook dropped it, so no turn comes of it
11 submit: (text: string) => Promise<boolean>;
12 // undefined once the engine queued it, else why it refused
13 send: (agent: string, text: string) => Promise<string | undefined>;
14 // retires every subscription the agent owns, once nothing can reach it
15 retire: (agent: string) => Promise<void>;
16 // the agent as the engine lists it now; undefined when it is not listed
17 status: (agent: string) => Promise<AgentStatus | undefined>;
18 after: (ms: number, fn: () => Promise<void>) => Timer;
19 log: (line: string) => void;
20};
21
22// how long a letter waits for its agent's next tool call before it goes by message
23export const GRACE_MS = 60_000;
24
25const FINISHED: ReadonlySet<string> = new Set(['completed', 'failed', 'killed']);
26
27// where a letter lands. one for the main loop rides its next tool call while a turn runs; between turns, every letter
28// waiting goes as one prompt, which starts a turn. one for an agent rides that agent's next tool call; after the grace,
29// or at once when the agent has ended, it goes as a message, which resumes an ended agent. a message the engine refuses
30// goes to the main loop as a relay and the agent's subscriptions are retired. letters that go together are one text,
31// a work item's older phases folded into its newest. helmd hands each letter out once, so two environments never both
32// deliver it
33export class Mailbox {
34 private readonly waiting = new Map<string, Letter>();
35 private readonly timers = new Map<string, Timer>();
36 // a main-loop turn runs, or a prompt this mailbox submitted is on its way to one: either ends at turn.complete
37 private busy = false;
38 // main-loop turn events seen, so a submission can tell whether they took over the busy flag while it waited
39 private moves = 0;
40 private readonly host: MailHost;
41
42 constructor(host: MailHost) {
43 this.host = host;
44 }
45
46 async receive(l: Letter): Promise<void> {
47 if (this.waiting.has(l.id)) return;
48 this.waiting.set(l.id, l);
49 if (l.agent === undefined) return this.prompt();
50 const status = await this.host.status(l.agent);
51 if (status === undefined || FINISHED.has(status)) return this.flush(l.agent);
52 this.arm(l.agent, Math.max(0, l.at + GRACE_MS - this.host.now()));
53 }
54
55 // the text that rides a tool call's result, the calling loop's letters taken from helmd as they go
56 async attach(agent: string | undefined): Promise<string | undefined> {
57 const key = agent ?? '';
58 const mine = this.mine(key);
59 if (!mine.length) return undefined;
60 this.disarm(key);
61 const taken: Letter[] = [];
62 for (const l of mine) {
63 this.waiting.delete(l.id);
64 const t = await this.host.take(l.id);
65 if (t) taken.push(t);
66 }
67 return taken.length ? deliveryText(taken) : undefined;
68 }
69
70 turnStarted(): void {
71 this.busy = true;
72 this.moves++;
73 }
74
75 // a main-loop turn ended: what is still waiting starts the next one
76 async turnEnded(): Promise<void> {
77 this.busy = false;
78 this.moves++;
79 await this.prompt();
80 }
81
82 // an agent's loop ended: what waits for it goes now, as a message that resumes it
83 async agentEnded(agent: string): Promise<void> {
84 if (this.mine(agent).length) await this.flush(agent);
85 }
86
87 pending(): { to: string; count: number }[] {
88 const counts = new Map<string, number>();
89 for (const l of this.waiting.values()) counts.set(l.agent ?? 'main', (counts.get(l.agent ?? 'main') ?? 0) + 1);
90 return [...counts].map(([to, count]) => ({ to, count }));
91 }
92
93 stop(): void {
94 for (const t of this.timers.values()) t.cancel();
95 this.timers.clear();
96 }
97
98 private mine(key: string): Letter[] {
99 return [...this.waiting.values()].filter((l) => (l.agent ?? '') === key).sort((a, b) => a.at - b.at);
100 }
101
102 private arm(key: string, ms: number): void {
103 if (this.timers.has(key)) return;
104 this.timers.set(
105 key,
106 this.host.after(ms, async () => {
107 this.timers.delete(key);
108 await this.flush(key);
109 }),
110 );
111 }
112
113 private disarm(key: string): void {
114 this.timers.get(key)?.cancel();
115 this.timers.delete(key);
116 }
117
118 // the main loop's waiting letters as one prompt, unless a turn runs or one is on its way. the loop is busy from the
119 // first take, so a letter that lands meanwhile waits for that turn instead of making a prompt of its own
120 private async prompt(): Promise<void> {
121 while (!this.busy && this.mine('').length) {
122 this.busy = true;
123 const moves = this.moves;
124 const text = await this.attach(undefined);
125 if (text !== undefined) await this.submit(text, false);
126 else if (moves === this.moves) this.busy = false;
127 }
128 }
129
130 // a prompt holds the loop busy until its turn ends; one a hook dropped starts no turn, so the loop goes back to how
131 // it was, unless a turn started or ended meanwhile and the turn events hold the flag
132 private async submit(text: string, was: boolean): Promise<void> {
133 this.busy = true;
134 const moves = this.moves;
135 const entered = await this.host.submit(text);
136 if (entered) return;
137 this.host.log('helm: a prompt of letters was dropped by a hook');
138 if (moves === this.moves) this.busy = was;
139 }
140
141 private async flush(agent: string): Promise<void> {
142 const text = await this.attach(agent);
143 if (text === undefined) return;
144 let refused: string | undefined;
145 try {
146 refused = await this.host.send(agent, text);
147 } catch (e) {
148 refused = (e as Error).message;
149 }
150 if (refused === undefined) return;
151 this.host.log(`helm: a message to agent ${agent} was refused (${refused}); relayed to the main loop`);
152 await this.submit([`[helm relay] for agent ${agent}, which a message could not reach: ${refused}`, text].join('\n'), this.busy);
153 await this.host.retire(agent);
154 }
155}
156src/mod/tools.ts 333 lines1import { forPerson, forSession, isOpen, isRecord } from '../core/decision.ts';
2import { workKey } from '../core/protocol.ts';
3import { describeScope, parseScope } from '../core/scope.ts';
4import { findNode } from '../core/tree.ts';
5import type { CiFilter, Decision, ReportState, RepoName, Until } from '../core/types.ts';
6import type { HelmClient } from './client.ts';
7import { HelmError } from './client.ts';
8import { decisionLine, fleetBlock, forgeBlock, jobLines, localBlock, pullLine, sessionLine, subscriptionLine, treeBlock, workBlock } from './format.ts';
9
10export type ToolEnv = {
11 client: HelmClient;
12 session: () => string;
13 home?: string;
14 now: () => number;
15 // the repository a call is about: the one it names, else the session's
16 repo: (given: unknown) => RepoName;
17 pending: () => { to: string; count: number }[];
18 version: string;
19};
20
21// one tool the model calls as mcp__helm__<name>. H is what the hooks layer hands a tool that needs the engine
22export type Tool<H = unknown> = {
23 name: string;
24 description: string;
25 inputSchema: Record<string, unknown>;
26 // listed with its schema from the first turn, for the tools agents reach for unprompted
27 eager: boolean;
28 // only the main loop may call it
29 mainOnly?: boolean;
30 run: (env: ToolEnv, input: Record<string, unknown>, agent: string | undefined, host: H) => Promise<string>;
31};
32
33const repoProp = { type: 'string', description: 'owner/name; defaults to the session repository' };
34
35const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
36const int = (v: unknown): number | undefined => (typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : typeof v === 'string' && /^#?\d+$/.test(v) ? Number(v.replace('#', '')) : undefined);
37const ints = (v: unknown): number[] => (Array.isArray(v) ? v.map(int).filter((n): n is number => n !== undefined) : int(v) !== undefined ? [int(v)!] : []);
38
39function needInt(v: unknown, what: string): number {
40 const n = int(v);
41 if (n === undefined) throw new HelmError(400, `${what} must be a positive number`);
42 return n;
43}
44
45// the open decisions, those for this session first
46function inbox(decisions: readonly Decision[], self: string, now: number): string {
47 const open = decisions.filter(isOpen);
48 return [...open.filter((d) => forSession(d, self)), ...open.filter((d) => !forSession(d, self))].map((d) => decisionLine(d, now, self)).join('\n');
49}
50
51const STATES: readonly ReportState[] = ['working', 'waiting', 'blocked', 'ready', 'stopped', 'abandoned'];
52
53export const TOOLS: Tool[] = [
54 {
55 name: 'status',
56 eager: true,
57 description:
58 'helm status for this session: its role and repository, its work with phases (queued, working, draft, ci, failing, ready, blocked, stalled, parked, done), the decisions addressed to it and its own waiting on the person, its subscriptions, letters waiting for its agents, and the web page. Call it at session start.',
59 inputSchema: { type: 'object', properties: {} },
60 async run(env) {
61 const f = await env.client.fleet();
62 const me = f.sessions.find((s) => s.id === env.session());
63 const mine = f.work.filter((w) => w.owner === env.session());
64 const subs = f.subscriptions.filter((s) => s.session === env.session());
65 const forMe = f.decisions.filter((d) => forSession(d, env.session()));
66 const asked = f.decisions.filter((d) => forPerson(d) && (d.from?.session === env.session() || mine.some((w) => w.repo === d.repo && w.issue === d.issue)));
67 const health = await env.client.health();
68 return [
69 `helm ${env.version} · helmd ${health.version} · ${health.web ?? ''}`,
70 me ? sessionLine(me, f.at, env.session()) : `session ${env.session()} not registered`,
71 '',
72 'work:',
73 workBlock(mine.filter((w) => w.phase !== 'done'), env.home),
74 '',
75 `decisions for this session (${forMe.length}):`,
76 ...forMe.map((d) => decisionLine(d, f.at, env.session())),
77 ...(asked.length ? ['', `waiting on the person (${asked.length}):`, ...asked.map((d) => decisionLine(d, f.at, env.session()))] : []),
78 '',
79 'subscriptions:',
80 ...subs.map((s) => subscriptionLine(s, describeScope)),
81 ...env.pending().map((p) => `letters waiting for ${p.to}: ${p.count}`),
82 ].join('\n');
83 },
84 },
85 {
86 name: 'view',
87 eager: true,
88 description:
89 'Read forge and machine state from helm instead of running gh or git: work (this session, or fleet for every session), tree (epics and their sub-issues with progress rolled up, across repositories; number narrows it to one subtree), issues (open, optional label), issue (one, with body and comments), prs (open, with checks), pr (one, with its jobs live), runs (recent workflow runs), worktrees, branches, decisions, sessions. Answers from a shared cache that one poller per repository keeps fresh, so it costs no rate limit.',
90 inputSchema: {
91 type: 'object',
92 properties: {
93 what: { type: 'string', enum: ['work', 'fleet', 'tree', 'issues', 'issue', 'prs', 'pr', 'runs', 'worktrees', 'branches', 'decisions', 'sessions'] },
94 repo: repoProp,
95 number: { type: 'number', description: 'issue or pr number, for issue and pr' },
96 label: { type: 'string', description: 'issues: only those with this label' },
97 },
98 required: ['what'],
99 },
100 async run(env, input) {
101 const what = str(input.what) ?? 'work';
102 const now = env.now();
103 if (what === 'tree') {
104 const f = await env.client.fleet(true);
105 const n = int(input.number);
106 if (n === undefined) return treeBlock(input.repo ? f.tree.filter((t) => t.repo === env.repo(input.repo)) : f.tree);
107 const hit = findNode(f.tree, env.repo(input.repo), n);
108 if (!hit) return `${env.repo(input.repo)}#${n} is in no epic helm sees`;
109 return [...(hit.path.length ? [`under ${hit.path.map((p) => `${p.repo}#${p.number}`).join(' › ')}`] : []), treeBlock([hit.node])].join('\n');
110 }
111 if (what === 'work' || what === 'fleet' || what === 'decisions' || what === 'sessions') {
112 const f = await env.client.fleet();
113 if (what === 'fleet') return fleetBlock(f, env.session(), env.home);
114 if (what === 'work') return workBlock(f.work.filter((w) => w.owner === env.session() && w.phase !== 'done'), env.home);
115 if (what === 'sessions') return f.sessions.map((s) => sessionLine(s, now, env.session())).join('\n') || 'no sessions';
116 return inbox(f.decisions, env.session(), now) || 'no open decisions';
117 }
118 const repo = env.repo(input.repo);
119 if (what === 'issue') {
120 const i = await env.client.issue(repo, needInt(input.number, 'number'));
121 return [`${i.pr ? 'pr' : 'issue'} ${repo}#${i.number} [${i.state}] ${i.title}`, `by ${i.author} · ${i.labels.join(', ') || 'no labels'} · ${i.url}`, '', i.body || '(no body)', ...i.comments.flatMap((c) => ['', `--- ${c.author} ${c.at}`, c.body])].join('\n');
122 }
123 const view = await env.client.repo(repo);
124 if (what === 'pr') {
125 const n = needInt(input.number, 'number');
126 const p = view.forge?.pulls.find((x) => x.number === n);
127 if (!p) return `pr #${n} is not among ${repo}'s open or recently closed pull requests`;
128 const runs = new Set(p.checks.map((c) => c.run));
129 const jobs = (view.forge?.runs ?? []).filter((r) => runs.has(r.id)).flatMap((r) => r.jobs ?? []);
130 return [pullLine(p), p.url, ...jobLines(jobs).map((l) => ` ${l}`), ...(p.lastReview ? [`last review ${p.lastReview.state} by ${p.lastReview.author.login}: ${p.lastReview.text}`] : []), ...(p.lastComment ? [`last comment by ${p.lastComment.author.login}: ${p.lastComment.text}`] : [])].join('\n');
131 }
132 const polled = view.polling.lastPoll ? `polled ${Math.round((now - view.polling.lastPoll) / 1000)}s ago${view.polling.error ? `, last error: ${view.polling.error}` : ''}` : 'not polled yet';
133 if (what === 'issues' || what === 'prs' || what === 'runs') return `${repo} ${what} (${polled})\n${forgeBlock(what, view.forge, now, str(input.label))}`;
134 if (what === 'worktrees' || what === 'branches') return localBlock(what, view.local, now, env.home);
135 throw new HelmError(400, `unknown view ${what}`);
136 },
137 },
138 {
139 name: 'log',
140 eager: false,
141 description:
142 'A CI job log, trimmed: by default the error lines with what led up to them. Name a job, or a run to read every failed job of it. GitHub serves a log only once its job completes; a running job shows its live steps in view pr.',
143 inputSchema: {
144 type: 'object',
145 properties: {
146 repo: repoProp,
147 run: { type: 'number', description: 'a workflow run id: reads the log of each of its failed jobs' },
148 job: { type: 'number', description: 'one job id' },
149 grep: { type: 'string', description: 'only lines matching this regular expression' },
150 tail: { type: 'number', description: 'the last n lines instead of the errors' },
151 errors: { type: 'boolean', description: 'error lines with context (default true unless grep or tail is given)' },
152 },
153 },
154 async run(env, input) {
155 const repo = env.repo(input.repo);
156 const tail = int(input.tail);
157 const grep = str(input.grep);
158 const errors = typeof input.errors === 'boolean' ? input.errors : !grep && !tail;
159 const q = { ...(tail ? { tail } : {}), ...(grep ? { grep } : {}), errors };
160 const job = int(input.job);
161 if (job) return env.client.log(repo, job, q);
162 const runId = needInt(input.run, 'run or job');
163 const view = await env.client.repo(repo);
164 const jobs = view.forge?.runs.find((r) => r.id === runId)?.jobs ?? [];
165 const failed = jobs.filter((j) => j.state === 'failure' || j.state === 'cancelled');
166 if (!failed.length) return jobs.length ? `run ${runId} has no failed job: ${jobLines(jobs).join('; ')}` : `run ${runId} is not among ${repo}'s recent runs, or its jobs are not read yet; pass a job id`;
167 const out: string[] = [];
168 for (const j of failed.slice(0, 4)) out.push(`=== ${j.name} (job ${j.id}) ${j.url}`, await env.client.log(repo, j.id, q).catch((e: Error) => e.message));
169 if (failed.length > 4) out.push(`… ${failed.length - 4} more failed jobs: ${failed.slice(4).map((j) => `${j.name} (${j.id})`).join(', ')}`);
170 return out.join('\n');
171 },
172 },
173 {
174 name: 'watch',
175 eager: true,
176 description:
177 'Subscribe to repository events, delivered to you as they happen instead of polling. subscribe: scope is repo, issue <n>, pr <n>, branch <name>, run <id> or tag <glob>; ci is settled (every verdict), failures, all or none; until settled, merged, closed or an ISO time removes it once reached. A subagent that subscribes owns the subscription: the delivery rides its next tool call, or after 60 s, or once it has ended its turn, arrives as a message that resumes it. So subscribe, then carry on or end your turn: never sleep or poll. To wait for CI on your PR: subscribe with scope "pr <n>", ci "settled", until "settled", and sha set to the head you pushed. unsubscribe takes the id; list shows this session\'s.',
178 inputSchema: {
179 type: 'object',
180 properties: {
181 action: { type: 'string', enum: ['subscribe', 'unsubscribe', 'list'] },
182 repo: repoProp,
183 scope: { type: 'string', description: 'repo (default), issue <n>, pr <n>, branch <name>, run <id>, tag <glob>, work or fleet' },
184 ci: { type: 'string', enum: ['settled', 'failures', 'all', 'none'] },
185 until: { type: 'string', description: 'settled, merged, closed or an ISO time' },
186 sha: { type: 'string', description: 'pr scope: the head you pushed, so a verdict on an older head is not taken for yours' },
187 tags: { type: 'array', items: { type: 'string' }, description: 'item events to take: opened, closed, reopened, merged, ready, draft, comment, review, pushed, edited, labeled' },
188 bots: { type: 'boolean', description: 'take events from bot accounts too' },
189 id: { type: 'string', description: 'unsubscribe: the subscription id' },
190 },
191 required: ['action'],
192 },
193 async run(env, input, agent) {
194 const action = str(input.action);
195 if (action === 'list') {
196 const f = await env.client.fleet();
197 return f.subscriptions.filter((s) => s.session === env.session()).map((s) => subscriptionLine(s, describeScope)).join('\n') || 'no subscriptions';
198 }
199 if (action === 'unsubscribe') return (await env.client.unsubscribe(str(input.id) ?? '')).removed ? `removed ${String(input.id)}` : `no subscription ${String(input.id)}`;
200 if (action !== 'subscribe') throw new HelmError(400, 'action is subscribe, unsubscribe or list');
201 const scope = parseScope(str(input.scope));
202 const spans = scope.kind === 'work' || scope.kind === 'fleet';
203 const untilText = str(input.until);
204 const until: Until | undefined = untilText === undefined ? undefined : untilText === 'settled' || untilText === 'merged' || untilText === 'closed' ? untilText : Number.isNaN(Date.parse(untilText)) ? undefined : { at: new Date(Date.parse(untilText)).toISOString() };
205 if (untilText !== undefined && until === undefined) throw new HelmError(400, `until ${untilText} is not settled, merged, closed or a time`);
206 const sub = await env.client.subscribe({
207 session: env.session(),
208 scope,
209 ...(spans ? {} : { repo: env.repo(input.repo) }),
210 ...(str(input.ci) ? { ci: str(input.ci) as CiFilter } : {}),
211 ...(Array.isArray(input.tags) ? { tags: input.tags.map(String) } : {}),
212 ...(typeof input.bots === 'boolean' ? { bots: input.bots } : {}),
213 ...(until ? { until } : {}),
214 ...(str(input.sha) ? { sha: str(input.sha)! } : {}),
215 ...(agent ? { agent } : {}),
216 });
217 const who = agent ? `for this agent (${agent}). A delivery arrives with the result of your next tool call; if you make none within 60 s or have ended your turn, it arrives as a message that resumes you. Do not wait or poll: carry on, or end your turn.` : 'for the main loop. Deliveries arrive as prompts, or with the next tool result while a turn runs.';
218 return `${subscriptionLine(sub, describeScope)}\nsubscribed ${who}`;
219 },
220 },
221 {
222 name: 'report',
223 eager: true,
224 description:
225 'Tell helm where your work on an issue stands, so the person and the session that owns it can track it. Call it with working when you start an issue (this claims it for you), waiting when you end your turn to wait for CI, ready when the PR is ready and green, blocked with a question when you cannot continue without a decision (a question from an agent goes to the session that owns the work, which answers it or escalates it to the person, and one from a session itself goes to the person; the answer resumes you), stopped when you stop for any other reason (this parks the work, and nothing helm delivers resumes you until the issue is dispatched again or your session messages you), abandoned when told to. choices lists decisions you made that the issue did not settle: records for the person to review, which need no answer. plan is your plan as it stands, every step with whether it is done: send it whole each time it changes, so the person sees your progress.',
226 inputSchema: {
227 type: 'object',
228 properties: {
229 issue: { type: 'number' },
230 repo: repoProp,
231 state: { type: 'string', enum: STATES },
232 note: { type: 'string', description: 'one line on where it stands' },
233 question: {
234 type: 'object',
235 properties: { title: { type: 'string' }, body: { type: 'string', description: 'what you found, the options and what you would do' }, options: { type: 'array', items: { type: 'string' } } },
236 required: ['title', 'body'],
237 },
238 choices: { type: 'array', items: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title', 'body'] } },
239 plan: {
240 type: 'array',
241 description: 'the whole plan, in order, each step one short line',
242 items: { type: 'object', properties: { text: { type: 'string' }, done: { type: 'boolean' } }, required: ['text', 'done'] },
243 },
244 },
245 required: ['issue', 'state'],
246 },
247 async run(env, input, agent) {
248 const repo = env.repo(input.repo);
249 const issue = needInt(input.issue, 'issue');
250 const state = str(input.state) as ReportState;
251 if (!STATES.includes(state)) throw new HelmError(400, `state is one of ${STATES.join(', ')}`);
252 if (state === 'blocked' && !input.question) throw new HelmError(400, 'blocked needs a question: what decision you need, with the options');
253 // a report from an agent claims the issue for it; the session's own report claims it for the session
254 if (state === 'working') await env.client.claim({ session: env.session(), repo, issue, ...(agent ? { agent } : {}) });
255 const q = input.question as { title?: unknown; body?: unknown; options?: unknown } | undefined;
256 const choices = Array.isArray(input.choices) ? (input.choices as { title?: unknown; body?: unknown }[]) : [];
257 const plan = Array.isArray(input.plan) ? (input.plan as { text?: unknown; done?: unknown }[]).map((p) => ({ text: String(p.text ?? ''), done: p.done === true })).filter((p) => p.text) : undefined;
258 const out = await env.client.report({
259 session: env.session(),
260 repo,
261 issue,
262 state,
263 ...(agent ? { agent } : {}),
264 ...(str(input.note) ? { note: str(input.note)! } : {}),
265 ...(q ? { question: { title: String(q.title ?? ''), body: String(q.body ?? ''), ...(Array.isArray(q.options) ? { options: q.options.map(String) } : {}) } } : {}),
266 ...(choices.length ? { choices: choices.map((c) => ({ title: String(c.title ?? ''), body: String(c.body ?? '') })) } : {}),
267 ...(plan ? { plan } : {}),
268 });
269 const asked = out.decisions.filter((d) => d.blocking);
270 return [
271 `${workKey(repo, issue)} reported ${state}`,
272 ...out.decisions.map((d) => (isRecord(d) ? `recorded ${d.id} for review (${d.kind}): ${d.title}` : `asked ${d.id} of ${d.to === 'person' ? 'the person' : `session ${d.session}`}: ${d.title}`)),
273 ...(asked.length ? ['End your turn now. The answer arrives as a message that resumes you.'] : []),
274 ].join('\n');
275 },
276 },
277 {
278 name: 'backlog',
279 eager: false,
280 mainOnly: true,
281 description: "This session's backlog of issues, kept in helm so the person sees what the session owns. list, add (issues, queued in order), remove (released), order (issues in the order to work them).",
282 inputSchema: {
283 type: 'object',
284 properties: { action: { type: 'string', enum: ['list', 'add', 'remove', 'order'] }, repo: repoProp, issues: { type: 'array', items: { type: 'number' } } },
285 required: ['action'],
286 },
287 async run(env, input) {
288 const action = str(input.action);
289 const issues = ints(input.issues);
290 if (action === 'list') {
291 const f = await env.client.fleet();
292 return workBlock(f.work.filter((w) => w.owner === env.session() && w.phase !== 'done'), env.home);
293 }
294 if (!issues.length) throw new HelmError(400, 'issues is required');
295 const repo = env.repo(input.repo);
296 if (action === 'add') return (await env.client.queue({ session: env.session(), repo, issues })).map((w) => `queued ${workKey(w.repo, w.issue)} ${w.title}`).join('\n');
297 if (action === 'remove') {
298 for (const issue of issues) await env.client.release({ session: env.session(), repo, issue });
299 return `released ${issues.map((n) => `#${n}`).join(' ')}`;
300 }
301 if (action === 'order') {
302 await env.client.order({ session: env.session(), keys: issues.map((n) => workKey(repo, n)) });
303 return `ordered ${issues.map((n) => `#${n}`).join(' ')}`;
304 }
305 throw new HelmError(400, 'action is list, add, remove or order');
306 },
307 },
308 {
309 name: 'decide',
310 eager: false,
311 description: 'The decision inbox. list shows the open decisions, each with who it is for: those for this session first (its agents\' questions and its stalls), then the person\'s and other sessions\'. answer one (text and, where it has options, an option; a routing record takes a tier name to reroute), escalate one addressed to this session to the person when its answer is not within this session\'s authority (text is an optional note), or dismiss one. The answer goes to the agent or session waiting on it. Choices and routing picks are records for the person to review, not decisions.',
312 inputSchema: {
313 type: 'object',
314 properties: { action: { type: 'string', enum: ['list', 'answer', 'escalate', 'dismiss'] }, id: { type: 'string' }, text: { type: 'string' }, option: { type: 'string' } },
315 required: ['action'],
316 },
317 async run(env, input) {
318 const action = str(input.action);
319 const id = str(input.id);
320 if (action === 'list') {
321 const f = await env.client.fleet();
322 return inbox(f.decisions, env.session(), f.at) || 'no open decisions';
323 }
324 if (!id) throw new HelmError(400, 'id is required');
325 if (action === 'dismiss') return `dismissed ${(await env.client.dismiss(id)).id}`;
326 if (action === 'escalate') return `escalated ${(await env.client.escalate(id, { by: `session ${env.session()}`, ...(str(input.text) ? { note: str(input.text)! } : {}) })).id} to the person`;
327 if (action !== 'answer') throw new HelmError(400, 'action is list, answer, escalate or dismiss');
328 const out = await env.client.answer(id, { text: str(input.text) ?? '', ...(str(input.option) ? { option: str(input.option)! } : {}), by: `session ${env.session()}` });
329 return `answered ${out.decision.id}${out.delivered ? ', delivered' : ', but nobody live was waiting on it'}`;
330 },
331 },
332];
333src/mod/view.ts 323 lines1import { isDone, isPassing } from '../core/checks.ts';
2import { forPerson, forSession } from '../core/decision.ts';
3import { percent } from '../core/tree.ts';
4import type { Decision, Fleet, Job, Phase, Rollup, Run, SessionRole, TreeNode, WorkView } from '../core/types.ts';
5import { selfWorked } from '../core/work.ts';
6import { ago } from './format.ts';
7
8// what a surface draws: rows of styled segments, so the drawing is plain data the hooks module maps onto elements.
9// a segment with press is a control: its press is the address the hooks module routes, its hotkey one key
10export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean; press?: string; hotkey?: string; boxed?: boolean };
11export type Row = Seg[];
12
13export type Self = { session: string; role: SessionRole; repo?: string };
14
15export type Tab = 'work' | 'epics' | 'ci' | 'inbox';
16export const TABS: readonly { id: Tab; label: string; hotkey: string }[] = [
17 { id: 'work', label: 'Work', hotkey: '1' },
18 { id: 'epics', label: 'Epics', hotkey: '2' },
19 { id: 'ci', label: 'CI', hotkey: '3' },
20 { id: 'inbox', label: 'Inbox', hotkey: '4' },
21];
22export const isTab = (t: unknown): t is Tab => TABS.some((x) => x.id === t);
23
24export type PaneOpts = { rows: number; cols: number; tab: Tab; web?: string };
25
26const PHASE_COLOR: Record<Phase, string> = {
27 queued: 'inactive',
28 working: 'claude',
29 draft: 'claude',
30 ci: 'warning',
31 failing: 'error',
32 ready: 'suggestion',
33 blocked: 'error',
34 stalled: 'warning',
35 parked: 'planMode',
36 done: 'success',
37};
38
39const PHASE_GLYPH: Record<Phase, string> = { queued: '○', working: '●', draft: '◐', ci: '◔', ready: '◆', failing: '✗', blocked: '■', stalled: '◌', parked: '‖', done: '✓' };
40
41// the order work is listed in: what needs someone first, then what moves, then what waits
42const PHASE_RANK: Record<Phase, number> = { blocked: 0, failing: 1, stalled: 2, ready: 3, ci: 4, draft: 5, working: 6, queued: 7, parked: 8, done: 9 };
43const byRank = (a: WorkView, b: WorkView) => PHASE_RANK[a.phase] - PHASE_RANK[b.phase] || a.order - b.order;
44
45const short = (model: string) => model.replace(/^claude-/, '').replace(/-(\d+)-(\d+)$/, ' $1.$2');
46const repoShort = (r: string) => r.split('/')[1] ?? r;
47const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
48
49// decisions are the open ones in scope
50export function counts(work: readonly WorkView[], decisions: readonly Decision[]) {
51 const open = work.filter((w) => w.phase !== 'done');
52 return {
53 active: open.filter((w) => w.phase !== 'queued' && w.phase !== 'parked').length,
54 queued: open.filter((w) => w.phase === 'queued').length,
55 parked: open.filter((w) => w.phase === 'parked').length,
56 ci: open.filter((w) => w.phase === 'ci').length,
57 attention: open.filter((w) => w.phase === 'blocked' || w.phase === 'failing' || w.phase === 'stalled').length,
58 waiting: decisions.filter((d) => d.blocking).length,
59 open: decisions.filter((d) => !d.blocking).length,
60 };
61}
62
63function summary(c: ReturnType<typeof counts>): Row {
64 const row: Row = [{ text: `${c.active} active`, bold: true }];
65 if (c.queued) row.push({ text: ` · ${c.queued} queued`, dim: true });
66 if (c.parked) row.push({ text: ` · ${c.parked} parked`, dim: true });
67 if (c.ci) row.push({ text: ` · ${c.ci} in ci`, color: 'warning' });
68 if (c.attention) row.push({ text: ` · ${c.attention} need attention`, color: 'error' });
69 if (c.waiting) row.push({ text: ` · ${c.waiting} waiting on a decision`, color: 'error', bold: true });
70 if (c.open) row.push({ text: ` · ${c.open} to decide`, dim: true });
71 return row;
72}
73
74// what this session sees: its own work and the decisions addressed to it, or for a coordinator all work and the
75// person's decisions besides its own
76function scope(f: Fleet, self: Self) {
77 const fleet = self.role === 'coordinator';
78 const work = f.work.filter((w) => w.phase !== 'done' && (fleet || w.owner === self.session));
79 const decisions = f.decisions.filter((d) => forSession(d, self.session) || (fleet && forPerson(d)));
80 const repos = fleet ? Object.keys(f.repos) : [...new Set([...(self.repo ? [self.repo] : []), ...work.map((w) => w.repo)])];
81 return { fleet, work, decisions, repos };
82}
83
84// a bar of parts over a total, `width` cells: each part with any count gets at least one cell, the rest is track
85export function bar(parts: readonly { n: number; color: string }[], total: number, width: number): Seg[] {
86 if (total <= 0 || width <= 0) return [{ text: '─'.repeat(Math.max(0, width)), dim: true }];
87 const shown = parts.filter((p) => p.n > 0);
88 const exact = shown.map((p) => (p.n / total) * width);
89 const cells = exact.map((x) => Math.max(1, Math.floor(x)));
90 let left = Math.round((shown.reduce((a, p) => a + p.n, 0) / total) * width) - cells.reduce((a, b) => a + b, 0);
91 const order = exact.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r);
92 for (let k = 0; left > 0 && k < order.length; k++, left--) cells[order[k]!.i]!++;
93 while (cells.reduce((a, b) => a + b, 0) > width) {
94 const i = cells.indexOf(Math.max(...cells));
95 cells[i]!--;
96 }
97 const out: Seg[] = shown.map((p, i) => ({ text: '━'.repeat(cells[i]!), color: p.color }));
98 const used = cells.reduce((a, b) => a + b, 0);
99 if (used < width) out.push({ text: '─'.repeat(width - used), dim: true });
100 return out;
101}
102
103export function meter(done: number, total: number, width: number, color = 'success'): Seg[] {
104 const on = total ? Math.round((done / total) * width) : 0;
105 return [
106 { text: '▰'.repeat(on), color },
107 { text: '▱'.repeat(width - on), dim: true },
108 ];
109}
110
111export function rollupParts(r: Rollup): { n: number; color: string }[] {
112 return [
113 { n: r.done, color: 'success' },
114 { n: r.ready, color: 'suggestion' },
115 { n: r.ci, color: 'warning' },
116 { n: Math.max(0, r.active - r.ci - r.ready), color: 'claude' },
117 { n: r.attention, color: 'error' },
118 { n: r.queued, color: 'inactive' },
119 { n: r.parked, color: PHASE_COLOR.parked },
120 ];
121}
122
123function tabRow(f: Fleet, self: Self, tab: Tab): Row {
124 const s = scope(f, self);
125 const runs = s.repos.flatMap((r) => f.repos[r]?.runs ?? []);
126 const badge: Record<Tab, string> = {
127 work: s.work.length ? ` ${s.work.length}` : '',
128 epics: '',
129 ci: runs.some((r) => !isDone(r.state)) ? ` ${runs.filter((r) => !isDone(r.state)).length}` : '',
130 inbox: s.decisions.length ? ` ${s.decisions.length}` : '',
131 };
132 const row: Row = [];
133 for (const t of TABS) {
134 if (row.length) row.push({ text: ' ' });
135 row.push({ text: `${t.label}${badge[t.id]}`, press: `tab:${t.id}`, hotkey: t.hotkey, bold: t.id === tab, ...(t.id === tab ? { color: 'claude' } : t.id === 'inbox' && s.decisions.some((d) => d.blocking) ? { color: 'error' } : {}) });
136 }
137 return row;
138}
139
140function workRows(w: WorkView, o: { cols: number; fleet: boolean; now: number }): Row[] {
141 const wide = o.cols >= 56;
142 const head: Row = [
143 { text: `${PHASE_GLYPH[w.phase]} `, color: PHASE_COLOR[w.phase] },
144 { text: `${w.phase.padEnd(8)}`, color: PHASE_COLOR[w.phase], bold: w.phase === 'blocked' || w.phase === 'failing' },
145 { text: `${o.fleet ? repoShort(w.repo) : ''}#${w.issue} `, bold: true },
146 { text: w.title },
147 ];
148 const at = w.history?.at(-1)?.at ?? w.updatedAt;
149 if (wide) head.push({ text: ` · ${ago(at, o.now)}`, dim: true });
150 const rows: Row[] = [head];
151 const pad = { text: ' ' };
152 const steps = w.plan ?? [];
153 if (steps.length) {
154 const done = steps.filter((s) => s.done).length;
155 const next = steps.find((s) => !s.done);
156 rows.push([pad, ...meter(done, steps.length, clamp(steps.length, 4, 10)), { text: ` ${done}/${steps.length}`, dim: true }, ...(next ? [{ text: ` next: ${next.text}` }] : [])]);
157 } else if (w.report?.note) rows.push([pad, { text: w.report.note, dim: true }]);
158 if (w.checks.length) {
159 const ok = w.checks.filter((c) => isDone(c.state) && isPassing(c.state)).length;
160 const bad = w.checks.filter((c) => isDone(c.state) && !isPassing(c.state));
161 const run = w.checks.filter((c) => c.state === 'running').length;
162 const row: Row = [pad, { text: 'ci ', dim: true }, ...bar([{ n: ok, color: 'success' }, { n: bad.length, color: 'error' }, { n: run, color: 'warning' }], w.checks.length, 8), { text: ` ${ok}/${w.checks.length}`, dim: true }];
163 if (bad.length) row.push({ text: ` ✗ ${bad.map((c) => c.name).join(', ')}`, color: 'error' });
164 else {
165 const live = runningStep(w.jobs);
166 if (live) row.push({ text: ` ▸ ${live}`, dim: true });
167 }
168 rows.push(row);
169 }
170 for (const p of w.leftovers ?? []) rows.push([pad, { text: `left running pid ${p.pid} `, color: 'warning' }, { text: p.command, dim: true }]);
171 if (wide) {
172 const meta = [w.agent ? `agent ${w.agentStatus ?? '?'}` : selfWorked(w) ? 'its session' : 'no agent', w.routing ? `${w.routing.tier ?? ''} ${short(w.routing.model)}/${w.routing.effort}`.trim() : '', w.pull ? `pr #${w.pull.number}${w.pull.draft ? ' draft' : ''}` : '', w.decisions ? `${w.decisions} decision${w.decisions === 1 ? '' : 's'}` : ''].filter(Boolean);
173 rows.push([pad, { text: meta.join(' · '), dim: true }]);
174 }
175 return rows;
176}
177
178function runningStep(jobs: readonly Job[]): string | undefined {
179 for (const j of jobs) {
180 if (isDone(j.state)) continue;
181 const on = j.steps.find((s) => s.state === 'running');
182 const n = j.steps.filter((s) => isDone(s.state)).length;
183 return `${j.name}${j.steps.length ? ` ${n + (on ? 1 : 0)}/${j.steps.length}` : ''}${on ? ` ${on.name}` : ''}`;
184 }
185 return undefined;
186}
187
188function workTab(f: Fleet, self: Self, o: PaneOpts): Row[] {
189 const s = scope(f, self);
190 const rows: Row[] = [summary(counts(s.work, s.decisions))];
191 const width = clamp(o.cols - 4, 12, 48);
192 if (s.work.length) {
193 const phases: Phase[] = ['ready', 'ci', 'working', 'draft', 'failing', 'blocked', 'stalled', 'queued', 'parked'];
194 rows.push(bar(phases.map((p) => ({ n: s.work.filter((w) => w.phase === p).length, color: PHASE_COLOR[p] })), s.work.length, width));
195 }
196 const fmt = { cols: o.cols, fleet: s.fleet, now: f.at };
197 if (s.fleet) {
198 const live = f.sessions.filter((x) => !x.gone);
199 for (const x of live) {
200 const mine = s.work.filter((w) => w.owner === x.id).sort(byRank);
201 if (!mine.length) continue;
202 rows.push([], [{ text: x.repo ? repoShort(x.repo) : (x.title ?? x.id.slice(0, 8)), bold: true }, { text: ` ${x.role}${x.id === self.session ? ' (this session)' : ''} · ${x.agents.filter((a) => a.status === 'running').length} agents running`, dim: true }]);
203 for (const w of mine) rows.push(...workRows(w, fmt));
204 }
205 const orphans = s.work.filter((w) => !live.some((x) => x.id === w.owner)).sort(byRank);
206 if (orphans.length) {
207 rows.push([], [{ text: 'no live session', color: 'warning', bold: true }]);
208 for (const w of orphans) rows.push(...workRows(w, fmt));
209 }
210 } else {
211 if (!s.work.length) rows.push([], [{ text: 'nothing queued or active. queue issues with backlog, start them with dispatch.', dim: true }]);
212 for (const w of [...s.work].sort(byRank)) rows.push([], ...workRows(w, fmt));
213 }
214 return rows;
215}
216
217// the subtree's leaves that someone is on, what moves under an epic
218function moving(n: TreeNode): TreeNode[] {
219 if (!n.children.length) return n.phase && n.phase !== 'done' && n.phase !== 'parked' ? [n] : [];
220 return n.children.flatMap(moving);
221}
222
223function epicsTab(f: Fleet, self: Self, o: PaneOpts): Row[] {
224 const s = scope(f, self);
225 const touches = (n: TreeNode): boolean => s.repos.includes(n.repo) || n.children.some(touches);
226 const roots = f.tree.filter((t) => t.state === 'open' && (s.fleet || touches(t)));
227 if (!roots.length) return [[{ text: 'no epics here. an open issue with sub-issues is one.', dim: true }]];
228 const width = clamp(o.cols - 12, 10, 40);
229 const rows: Row[] = [];
230 const sorted = [...roots].sort((a, b) => b.rollup.active + b.rollup.attention - (a.rollup.active + a.rollup.attention) || percent(a.rollup) - percent(b.rollup));
231 for (const t of sorted) {
232 const r = t.rollup;
233 if (rows.length) rows.push([]);
234 rows.push([{ text: `${String(percent(r)).padStart(3)}% `, bold: true }, { text: `${repoShort(t.repo)}#${t.number} `, dim: true }, { text: t.title.replace(/^epic:\s*/i, ''), bold: true }]);
235 rows.push([{ text: ' ' }, ...bar(rollupParts(r), r.total, width), { text: ` ${r.done}/${r.total}`, dim: true }]);
236 const notes = [r.active ? `${r.active} active` : '', r.ci ? `${r.ci} in ci` : '', r.ready ? `${r.ready} ready` : '', r.queued ? `${r.queued} queued` : '', r.parked ? `${r.parked} parked` : '', r.unowned ? `${r.unowned} unowned` : ''].filter(Boolean);
237 if (r.attention) rows.push([{ text: ' ' }, { text: `${r.attention} need attention`, color: 'error' }, ...(notes.length ? [{ text: ` · ${notes.join(' · ')}`, dim: true }] : [])]);
238 else if (notes.length) rows.push([{ text: ' ' }, { text: notes.join(' · '), dim: true }]);
239 for (const n of moving(t).sort((a, b) => PHASE_RANK[a.phase!] - PHASE_RANK[b.phase!]).slice(0, 4)) {
240 rows.push([{ text: ' ' }, { text: `${PHASE_GLYPH[n.phase!]} `, color: PHASE_COLOR[n.phase!] }, { text: `${n.repo === t.repo ? '' : repoShort(n.repo)}#${n.number} `, bold: true }, { text: n.title }, ...(n.plan ? [{ text: ` ${n.plan.done}/${n.plan.total}`, dim: true }] : [])]);
241 }
242 }
243 return rows;
244}
245
246function runRows(repo: string, r: Run, o: { cols: number; now: number; many: boolean }): Row[] {
247 const live = !isDone(r.state);
248 const glyph = live ? '◔' : isPassing(r.state) ? '✓' : '✗';
249 const color = live ? 'warning' : isPassing(r.state) ? 'success' : 'error';
250 const head: Row = [{ text: `${glyph} `, color }, { text: r.workflow, bold: true }, { text: ` ${o.many ? `${repoShort(repo)} ` : ''}${r.tag ? 'tag ' : ''}${r.branch} · ${live ? 'started' : r.state} ${ago(Date.parse(live ? r.createdAt : r.updatedAt), o.now)} ago`, dim: true }];
251 const rows: Row[] = [head];
252 const jobs = r.jobs ?? [];
253 if (!jobs.length) return rows;
254 const ok = jobs.filter((j) => isDone(j.state) && isPassing(j.state)).length;
255 const bad = jobs.filter((j) => isDone(j.state) && !isPassing(j.state));
256 const run = jobs.filter((j) => j.state === 'running');
257 if (live) rows.push([{ text: ' ' }, ...bar([{ n: ok, color: 'success' }, { n: bad.length, color: 'error' }, { n: run.length, color: 'warning' }], jobs.length, clamp(o.cols - 18, 8, 32)), { text: ` ${ok + bad.length}/${jobs.length} jobs`, dim: true }]);
258 for (const j of run.slice(0, 3)) {
259 const on = j.steps.find((s) => s.state === 'running');
260 const n = j.steps.filter((s) => isDone(s.state)).length;
261 rows.push([{ text: ' ▸ ', color: 'warning' }, { text: j.name }, ...(j.steps.length ? [{ text: ` ${n + (on ? 1 : 0)}/${j.steps.length}`, dim: true }] : []), ...(on ? [{ text: ` ${on.name}`, dim: true }] : [])]);
262 }
263 for (const j of bad.slice(0, 3)) rows.push([{ text: ' ✗ ', color: 'error' }, { text: j.name }, { text: ` job ${j.id}`, dim: true }]);
264 return rows;
265}
266
267function ciTab(f: Fleet, self: Self, o: PaneOpts): Row[] {
268 const s = scope(f, self);
269 const runs = s.repos.flatMap((repo) => (f.repos[repo]?.runs ?? []).map((r) => ({ repo, r }))).sort((a, b) => Number(isDone(a.r.state)) - Number(isDone(b.r.state)) || Date.parse(b.r.createdAt) - Date.parse(a.r.createdAt));
270 if (!runs.length) return [[{ text: 'no run in flight or finished in the last half hour.', dim: true }]];
271 const live = runs.filter((x) => !isDone(x.r.state)).length;
272 const failed = runs.filter((x) => isDone(x.r.state) && !isPassing(x.r.state)).length;
273 const rows: Row[] = [[{ text: `${live} in flight`, bold: true }, ...(failed ? [{ text: ` · ${failed} failed`, color: 'error' }] : []), { text: ` · ${runs.length - live - failed} passed lately`, dim: true }]];
274 const fmt = { cols: o.cols, now: f.at, many: s.repos.length > 1 };
275 for (const { repo, r } of runs) rows.push([], ...runRows(repo, r, fmt));
276 return rows;
277}
278
279function inboxTab(f: Fleet, self: Self, o: PaneOpts): Row[] {
280 const s = scope(f, self);
281 if (!s.decisions.length) return [[{ text: s.fleet ? 'nothing waits on you or this session.' : 'nothing is addressed to this session.', dim: true }]];
282 const rows: Row[] = [];
283 const sorted = [...s.decisions].sort((a, b) => Number(b.blocking) - Number(a.blocking) || b.createdAt - a.createdAt);
284 for (const d of sorted) {
285 if (rows.length) rows.push([]);
286 rows.push([{ text: `${d.blocking ? 'waiting' : d.kind}`.padEnd(9), color: d.blocking ? 'error' : 'inactive', bold: d.blocking }, { text: d.repo ? `${repoShort(d.repo)}${d.issue !== undefined ? `#${d.issue}` : ''} ` : '', bold: true }, { text: d.title }, { text: ` · ${ago(d.createdAt, f.at)}`, dim: true }]);
287 const first = d.body.split('\n').find((l) => l.trim());
288 if (first) rows.push([{ text: ' ' }, { text: first, dim: true }]);
289 const controls: Row = [{ text: ' ' }];
290 for (const [i, option] of (d.options ?? []).entries()) controls.push({ text: option, press: `answer:${d.id}:${i}`, boxed: true }, { text: ' ' });
291 if (d.to === 'session') controls.push({ text: 'escalate', press: `escalate:${d.id}`, dim: true }, { text: ' ' });
292 controls.push({ text: 'dismiss', press: `dismiss:${d.id}`, dim: true });
293 rows.push(controls);
294 }
295 if (o.web) rows.push([], [{ text: `a written answer: ${o.web}`, dim: true }]);
296 return rows;
297}
298
299const TAB_ROWS: Record<Tab, (f: Fleet, self: Self, o: PaneOpts) => Row[]> = { work: workTab, epics: epicsTab, ci: ciTab, inbox: inboxTab };
300
301// the pane: the tab row, then the tab's rows cut to the room there is
302export function paneRows(f: Fleet, self: Self, o: PaneOpts): Row[] {
303 const body = TAB_ROWS[o.tab](f, self, o);
304 const room = Math.max(2, o.rows - 2);
305 const cut = body.length <= room ? body : [...body.slice(0, room - 1), [{ text: `… ${body.length - room + 1} more rows`, dim: true }]];
306 return [tabRow(f, self, o.tab), [], ...cut];
307}
308
309// one line above the prompt while something needs the person, nothing otherwise
310export function bandRow(f: Fleet, self: Self): Row | undefined {
311 const s = scope(f, self);
312 const c = counts(s.work, s.decisions);
313 if (!c.attention && !c.waiting) return undefined;
314 return [{ text: 'helm ', color: 'claude', bold: true }, ...summary(c)];
315}
316
317export function statusText(f: Fleet, self: Self): string | undefined {
318 const s = scope(f, self);
319 if (!s.work.length && !s.decisions.some((d) => d.blocking)) return undefined;
320 const c = counts(s.work, s.decisions);
321 return [`helm ${c.active}▸`, c.ci ? `${c.ci}ci` : '', c.attention ? `${c.attention}!` : '', c.waiting ? `${c.waiting}?` : ''].filter(Boolean).join(' ');
322}
323hooks/runtime.ts 54 lines1import { isRepoName } from '../src/core/repo.ts';
2import type { RepoName, SessionRole } from '../src/core/types.ts';
3import { type HelmClient, HelmError } from '../src/mod/client.ts';
4import type { Install } from '../src/mod/install.ts';
5import type { Mailbox } from '../src/mod/mailbox.ts';
6import type { ToolEnv } from '../src/mod/tools.ts';
7
8export const PLUGIN = 'helm';
9
10export const ROLES: readonly SessionRole[] = ['coordinator', 'repo', 'other'];
11
12// one session's binding to helmd, for one load of the module: a reload starts a new one and the old one's loops stop.
13// everything that reaches the engine lives in helm.tsx, since the engine follows $ into no other file; what is here
14// and under src/mod is plain logic over the ports helm.tsx builds
15export type Runtime = {
16 client: HelmClient;
17 version: string;
18 // this mod's own install, beside which the newest helmd is looked for
19 install: Install;
20 home: string;
21 session: string;
22 // the session's repository and role as helmd last answered them
23 repo?: RepoName;
24 role: SessionRole;
25 // the role asked for, by HELM_ROLE or /helm role, sent at each register; without one helmd decides
26 asked?: SessionRole;
27 // the repository of the checkout the session runs in, a default for a session helmd does not know
28 checkout?: RepoName;
29 mailbox: Mailbox;
30 // the web page helmd serves, once it has answered
31 web?: string;
32 // this load of the module, stamped as the binding's holder; alive goes false for good once another holds it
33 instance: string;
34 alive: boolean;
35 timers: { cancel: () => void }[];
36};
37
38export function toolEnv(r: Runtime): ToolEnv {
39 return {
40 client: r.client,
41 session: () => r.session,
42 home: r.home,
43 now: Date.now,
44 version: r.version,
45 pending: () => r.mailbox.pending(),
46 repo: (given) => {
47 if (isRepoName(given)) return given;
48 if (given !== undefined && given !== '') throw new HelmError(400, `repo ${String(given)} is not owner/name`);
49 if (!r.repo) throw new HelmError(400, 'this session has no repository: pass repo as owner/name');
50 return r.repo;
51 },
52 };
53}
54