SLOPSHOPPER

helm

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…

newpanebandguardcommandstatus
v0.6.0MITupdated 2026-10-09octalide/helm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · helm
│ ┃ helm ✕ › fix the failing auth test and add an audit log call │ ┃ helm is connecting to helmd │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · helm
helm is connecting to helmd
README

helm

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.

Install

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

How it fits together

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.

Roles

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.

  • Repo session. Owns a repository's issues. It queues them with backlog, starts agents with dispatch, and hears its own work move ([helm work] deliveries) without polling.
  • Coordinator. Sees the fleet (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.
  • Issue agent. Started only by 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.

Tools

tooldoes
statusthis session's role, work, the decisions addressed to it and its own waiting on the person, subscriptions and letters in flight
viewwork, 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
loga CI job's log trimmed to its errors, a grep or a tail; or every failed job of a run
watchsubscribe to a repository, issue, PR, branch, run or tag. A subagent's subscription delivers to that subagent
reportwhere 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
dispatchstart an issue agent per issue: claim, route to a tier, spawn, log the pick
backlogthe session's queue of issues
decidelist, 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.

Pane

/helm pane opens a dashboard beside the transcript, with four tabs. Each tab key works while the pane has the focus.

keytabshows
1Workthe 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
2Epicseach epic touching the session, with its percent, rollup bar and counts, and the work moving under it
3CIruns in flight with their job bar and running steps, then runs finished in the last half hour
4Inboxwhat 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.

Delivery

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.

Phases

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.

Succession

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.

Hierarchy

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.

Routing

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:

tiermodeleffortfor
mechanicalclaude-haiku-5-5highversion and pin bumps, renames, moves, docs, pattern-following data rows, finishing a done PR, one-file fixes with a stated cause
lightclaude-sonnet-5-5mediumsmall contained work in one module with a stated design
standardclaude-opus-5-5mediumordinary work in one subsystem with clear acceptance
deepclaude-opus-5-5highcross-subsystem or contract changes, codegen, concurrency, soundness, design-heavy work, unknown root causes
frontierclaude-fable-5-1highreserved 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.

Web page

http://127.0.0.1:7468/, or /helm web. It is a dashboard with a view per question, and each view updates live:

viewshows
Overviewactive 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
Boardwork 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
Epicsthe sub-issue tree across repositories, each epic with its rollup bar, each leaf with its phase, plan and agent; drill into any epic
Timelinea 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
CIruns in flight with every job and step, pass rate and run length, each workflow's recent outcomes, and failed runs with their logs
Agentseach live session's agents, their model and effort, and the work each is on
Routingpicks per tier by outcome, the judge's confidence, and every pick with its reason
Inboxwhat 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
Reviewrecords 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
Reposeach repository's PRs, issues, runs, worktrees and branches, and the GitHub budget left
Activityevery 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

~/.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

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.

Development

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

Source 21 files
hooks/helm.tsx 506 lines
1import { 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};
506
src/core/paths.ts 43 lines
1export 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}
43
src/core/protocol.ts 121 lines
1import { 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}
121
src/core/repo.ts 20 lines
1import 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}
20
src/core/types.ts 445 lines
1// 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};
445
src/mod/client.ts 111 lines
1import 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}
111
src/mod/install.ts 40 lines
1import { 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}
40
src/mod/dispatch.ts 110 lines
1import { 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}
110
src/mod/mailbox.ts 156 lines
1import { 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}
156
src/mod/tools.ts 333 lines
1import { 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];
333
src/mod/view.ts 323 lines
1import { 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}
323
hooks/runtime.ts 54 lines
1import { 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