LubbDubb at your keyboard: its skills, its desktop tool channel, and a notice board above the prompt.

<img src="docs/brand/logo.svg" alt="LubbDubb" width="120">
A self-hosted, always-running orchestration harness for one software engineer's work — a cockpit that watches your inputs (issues, PRs, CI, review comments), decides what to do on a heartbeat, and dispatches Claude Code agents to do it, escalating to you only what genuinely needs judgment.
The name is the heartbeat: the server's core is a periodic pulse that drives everything.
Where to read what.
docs/mission.mdis why it exists and what it changes about the job. This file is the overview: what the harness does, how work flows through it, and the configuration that matters on day one.docs/workflow.mdis the workflow in full, including where a different one slots in.docs/spec/is the specification of how every part of the application behaves today, written as fact — every config key, every dispatch rule, the agent runtimes, the API, the cockpit. If you want the detail behind anything below, it is in there.docs/feature-timeline.mdis how it got this way.
One repeating cycle, driven by a heartbeat (heartbeatIntervalMs, default 30s while the fleet is busy; idleHeartbeatIntervalMs, 5 minutes, while it is not) and also triggerable on demand:
snapshot the world → diff against the last snapshot → reconcile plans
→ decide (dispatcher) → execute (executor) → audit
Every step is recorded. The dispatcher's rationale, every action it emitted, and the outcome of each action are persisted — so an idle cycle is as explainable as a busy one. Agents run in pooled git worktrees (code work) or scratch dirs (desk work), and report back over a typed tool channel.
Grouped by where in the loop it sits. Each line links to the spec that owns it.
| Feature | What it is |
|---|---|
| Opt-in watching | One ${labelPrefix}-watch tag decides what is acted on, on issues and pull requests alike. Nothing outside it is touched. → [06][s06] |
| Two providers | GitHub and Azure DevOps behind per-capability seams, plus a fake provider the whole suite and the demo run on. → [15][s15] |
| Tracker states | Where the provider has workflow states, pickup is gated on them and the harness moves the item to in-progress and in-review. → [06][s06] |
| Priority and order | Priority labels weight pickup; the ranked plan ships as Up next with a cut-line at current headroom, re-orderable by you. → [05][s05] |
| Tickets board | Every open and closed item, as a table or a column-per-state board you drag cards across — the drop's cost said before it lands. → [17][s17] |
| Attachments | Images attached to a brief follow the issue to whichever agent works it, stored outside every worktree. → [12][s12] |
| Feature | What it is |
|---|---|
| A rule pipeline | Two dozen named rules walked in a declared order, each proposing work from the world. The order is data, not numbers on a comment. → [05][s05] |
| A bounded vocabulary | The dispatcher can only ever ask for one of eleven validated actions; anything malformed is rejected and audited, never executed. → [05][s05] |
| Per-check CI policy | What to do about which check went red — fix it, fix it with guidance, hold it because it is not ours, or escalate once. → [02][s02] |
| The decision log | Executed, deferred, rejected and skipped alike, each with its reason and the rule that produced it, expandable to why that rule exists. → [18][s18] |
| Cooldowns and caps | Per-origin attempt caps and cooldowns, so a dispatch that keeps failing escalates instead of looping. → [05][s05] |
| Feature | What it is |
|---|---|
| Agents in worktrees | A bounded pool of checkouts leased to branches and switched rather than recreated, so a branch that comes back starts warm. → [09][s09] |
| A typed tool channel | An MCP server agents call back on — read the world, raise what they learned, open a pull request, report a check. → [11][s11] |
| A permission backstop | An agent hitting a command outside the allow-list asks you rather than hanging. → [11][s11] |
| Models per rule | Named profiles — a model and the depth it runs at — assigned per dispatch rule, so a conflict fix and a plan are not priced alike. → [02][s02] |
| Jobs and schedules | An ad-hoc prompt queued from the cockpit, or queued for you on a cron expression. Both wait for a slot like everything else. → [13][s13] |
| Crash recovery | Agents orphaned by a restart are parked, and the pulse is held, until you restore, requeue or remove each one. → [10][s10] |
| Live transcripts | Click an agent and read what it is doing, type into it, and see what it produced mid-run. → [10][s10], [12][s12] |
| Feature | What it is |
|---|---|
| Health predicates | Failing CI, behind or conflicting with its base, unhandled review threads, ready to merge — one agent per branch, top concern first. → [07][s07] |
| The fleet review | Off by default: the harness reads a pull request of its own before a person is asked, with a triage that picks how thoroughly. → [07][s07] |
| Answering a review | Every unhandled thread goes to one agent, replied to through the harness — signed, recorded, and resolved on the provider's own threads. → [07][s07] |
| Stacks | A part is based on the part it depends on; a red base is attributed to the PR that owns it, and merging is bottom-up. → [07][s07] |
| Waiting on you | A pull request somebody assigned you, who asked, and how long it has been sitting there. → [17][s17] |
| Feature | What it is |
|---|---|
| Goal appraisal | Is there a goal here to work from? A refusal says what is missing, on the ticket, and lifts when the goal text changes. → [08][s08] |
| Planning | One pull request, or a dependency-chained decomposition into parts each with its own branch and scope — or "this is already done". → [08][s08] |
| Plan approval | Always. A plan carries risks, scope-outs and how anyone will know it worked, and can be discussed with a conversational planner. → [08][s08] |
| Assessment | Asked of what was delivered, not of the agent's confidence. Its no arm replans, adds a part, or escalates. → [08][s08] |
| Validation | Checks that can only be answered by running the delivered thing, written for a person — or handed to your own Claude Code. → [20][s20] |
| Retrospective | One write-up per delivered goal, from the shared scratchpad and the harness's own record of what it cost. → [13][s13] |
| Close-out | The harness never closes a ticket. It files a standing obligation with your name on it, which settles once the tracker stops listing it open. → [13][s13] |
| Feature | What it is |
|---|---|
| Environments | Off by default. The commit each PR landed as, and whether each environment has it yet — asked with your own command, three-valued. → [24][s24] |
| Arrivals | Arriving somewhere can be what opens what a delivered goal owes you, and what puts a line on the ticket. Both opt-in, per environment. → [24][s24] |
| Post-deploy watch | A goal declares what a running system must show; an arrival opens a window, and your telemetry is asked on a schedule. No model in it. → [29][s29] |
| Obstacles | What is in the fleet's way, keyed rather than matched on prose: two independent voices, an owner, and an exit that is never a person. → [27][s27] |
| The cross-fleet pool | The distance above fleet: one namespace per fleet in a shared repository, with a corroboration model and a digest. → [28][s28] |
| Feature | What it is |
|---|---|
| Needs you | One rail: escalations, plan approvals, outbound proposals, permission requests, config health, and the obligations a delivery leaves. → [17][s17] |
| Insights | What runs cost, what they yielded and what came out — plus a live burn watch that surfaces a run several times its bucket's median. → [18][s18] |
| The runway | Whether there is work left for the fleet, measured in fleet time — and whether the reason there is not is you. → [25][s25] |
| Config health | One row per setting that can stop the fleet silently, each ending in a check against the real world rather than advice. → [26][s26] |
| The error log | The one path every caught failure funnels through: persisted, mirrored to stderr, streamed to the cockpit. → [18][s18] |
| Self-update | The harness watches its own build, drains, and hands off to a supervisor that replaces it. → [21][s21] |
| Local runs | The machine's one dev environment, brought up and taken down from the cockpit, with its output in the pane the fleet already has. → [23][s23] |
| Pets | A vivarium at the foot of the rail. Your actions drop eggs; the eggs hatch. It gates nothing. → [22][s22] |
Two entry points, one path. A prompt states a goal and a ticket is found or created for it; a ticket states its own. Everything downstream keys on the ticket, so work started from a prompt is as recoverable, reviewable and reportable as work started from the tracker.
flowchart TD
P([Start with a prompt]) --> G[Goal is stated]
T([Start with a ticket]) --> TK
G -- find or create --> TK[Ticket]
TK --> V{Enough information<br/>to proceed?}
V -- no --> AL[Say what is missing,<br/>on the ticket]
AL --> UW([Stop working it])
UW -. the goal text changes .-> TK
V -- yes --> PL[Plan the work]
PL --> AP{Plan accepted?}
AP -- no, revise --> PL
AP -- yes --> WK[/Do the work/]
WK --> QG{Quality gates<br/>review, CI, a person}
QG -- not satisfied --> WK
QG -- satisfied --> M[Merge]
M --> GC{Goal achieved?}
GC -- no --> PL
GC -- yes --> DV[Deliver: validate, write up,<br/>hand back what is yours]
DV --> AR{Configured<br/>environments?}
AR -- yes --> EN[Watch it arrive,<br/>then watch it behave]
AR -- no --> D
EN --> D([Done])
| Step | What happens |
|---|---|
| Intake | Every open issue/PR is fetched and shown. What is acted on is decided by the watch tag, plus tracker workflow states where the provider has them. |
| Enough information? | One agent reads the ticket against the repository and says whether there is a goal here to work from. Only an explicit unclear holds anything. |
| Plan the work | A planning agent reads the repo and returns either one PR will do, a decomposition into dependency-chained parts, or this goal is already met. |
| Plan accepted? | Every plan verdict appears in Needs you. Accepting releases the parts to be scheduled; rejecting falls the issue back to the single-PR path, and it can be held. |
| Do the work | An agent per part (or one for the whole issue), each in its own worktree. Code is the most common arm, not the only one — a part may finish with a report. |
| Quality gates | The fleet's own review of the diff (opt-in), then tests, static analysis, pipeline health and human review. Each failing check is classified per check. |
| Merge | A green, approved, mergeable, comment-clear PR is merged — bottom-up for a stack, and never while it is based on another in-flight branch. |
| Goal achieved? | Asked of what was actually delivered, not of the agent's confidence. A no returns to planning, because what is missing may be a different decomposition. |
| Deliver | A validation sheet of checks only a person or a running system can answer; a retrospective written from the record; the tracker state moved and a status comment. |
| Close out | Nothing closes the ticket. A standing obligation with your name on it is filed, and settles itself once the tracker stops listing the item open. |
| Arrival | Where environments are configured: the commit each PR landed as, whether each environment has it yet, and — where declared — whether it is behaving now it is there. |
Each is a decision something has to make, not a step that always passes.
no arm proposes a replan, a follow-up part, or escalates — depending on what the assessment says fell short.The dispatcher ranks every candidate, then applies the concurrency cut. Roughly, highest first:
PR work runs before new issue pickup, so a PR in trouble is always worked ahead of starting new tickets. The full ordered plan ships to the cockpit as Up next, with a cut-line at the current headroom; you can re-order it, and the override persists.
The harness owns the loop; you own the verdicts. You are asked when — and only when — a decision is genuinely yours:
sendPrRepliesWithoutApproval, on by default, which sends a drafted review reply straight to the thread. A rejection stands until the world gives a reason to ask again — a push, a CI result, an approval, a comment — and the reason you typed is handed to the next agent that works that item.Two things are deliberately fixed: every act reaching the outside world is authorized, and an agent declares that it finished — silence never reads as success.
Node >=22.12 and git. A fresh clone installs with npm ci — node-pty is a native build, so it is not instant (on npm 12+ it builds because package.json lists it in allowScripts).
npm ci # native dep: node-pty
cp lubbdubb.config.example.json lubbdubb.config.json # your local config (gitignored)
npm start # builds the cockpit, serves on 127.0.0.1:4300
Every key in the config is optional and the harness boots with no file at all — but the defaults select the real Claude Code runtime, while the shipped example selects the mock one (agentMode: "raw" against the built-in fake providers). So copy it for a first run and the whole loop turns with no model or provider credentials. From then on the cockpit's Config page edits that same file directly, key by key, leaving its comments and ordering alone — hand-editing and the form are two ways at one file.
npm start builds the cockpit bundle and then runs the server. Two variants matter: npm run start:server skips the build and serves whatever web/dist already holds, and npm run serve runs the server under the supervisor that can replace it — which is what self-update needs, and the way to run it under systemd, NSSM or a long-lived terminal. → docs/spec/21
Boot prints what it decided, and the link to open:
[lubbdubb] cockpit listening on 127.0.0.1:4300
[lubbdubb] open the cockpit: http://127.0.0.1:4300/#t=<token>
[lubbdubb] token minted at .lubbdubb/cockpit-token (0600) — reused on the next start
[lubbdubb] heartbeat=300000ms cap=3
[lubbdubb] agent tools: on
Open it once per browser and the cockpit remembers the token. The harness binds loopback only and every route needs that token, because the cockpit can queue a job — and a job spawns a real agent with write access to your repo. The token file is gitignored, along with the rest of .lubbdubb/ (the SQLite database, worktrees, desk scratch dirs and attachments all live under it).
The harness ships a Claude Code plugin for your own Claude Code: the /lubbdubb:… skills every Open in Claude Code link in the cockpit calls (/lubbdubb:check 284:C, /lubbdubb:plan 284, /lubbdubb:ask 284, …), the desktop tool channel those skills talk to, and a notice board: a status line above your prompt and a panel beside the transcript with what the harness is waiting on you for, feature progress, pull request states, the fleet and what is up next. Boot writes it to ~/.lubbdubb/plugin:
[lubbdubb] Claude Code plugin 1.0.0-… written to ~/.lubbdubb/plugin — install or update it from the cockpit's MCP tab
Until it is installed the cockpit shows a banner under the top bar; Install it opens the MCP tab, whose button runs claude plugin marketplace add and claude plugin install for you at user scope, and removes the hand-registered lubbdubb MCP server and the old /lubbdubb skill if you had them. Restart open Claude Code sessions to pick it up. Skip it and nothing breaks: every check simply falls to the fleet. → docs/spec/11
Then: use Inject event to simulate the world moving (a CI failure, a review comment) and watch the harness react; click an agent to see its live transcript and type into it; answer items in Needs you; use New job to launch an ad-hoc prompt, or New schedule to have one queued on a cron expression (0 9 * * 1-5 — weekdays at nine, read in the harness's own timezone). The Decision log shows what was decided each cycle and which rule produced it; Activity shows how the world itself changed.
Needs you also carries the configuration checks — one row per setting that can stop the fleet silently, each ending in a check against the real world rather than a sentence of advice. On a fresh install that rail is the shortest route from the mock loop to a working deployment. → docs/spec/26
Every key is optional, and every key, its default and its precedence is in docs/spec/02-configuration.md. These are the ones that decide whether a deployment works at all:
| Key | Default | Why it matters |
|---|---|---|
repoRoot | the directory you launch in | The git repository worktrees are cut from. Left alone, the harness works on its own checkout. |
| integrations | all fake | Which provider serves each capability — sourceControl, issues, pool.
hooks/register.tsx 459 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Feature, PrTone } from '../types'
5import { bandParts, clip, fresh, summary, toBoard, type FeaturesReply, type StateReply } from './board'
6import { LOGO_COLUMNS, LOGO_FRAMES, LOGO_ROWS, logoCells, logoSvg } from './logo'
7
8const board = atom({ plugin: 'lubbdubb', key: 'board' } as const, null)
9const isHidden = atom({ plugin: 'lubbdubb', key: 'isHidden' } as const, false)
10const isPaneOpen = atom({ plugin: 'lubbdubb', key: 'isPaneOpen' } as const, false)
11
12const POLL_MS = 30_000
13const TICK_MS = 5_000
14const LEASE_MS = 3 * POLL_MS
15const LEASE_KEY = 'leader'
16const SNAPSHOT_KEY = 'snapshot'
17const PANE = 'lubbdubb'
18const TITLE = 'LubbDubb'
19const BAR = 10
20const LOGO_KEY = 'logo'
21const BEAT_MS = 100
22const LOGO_MIN_WIDTH = 40
23
24type Lease = { owner: string; until: number }
25type Snapshot = { at: number; board: Board | null }
26
27let beat = 0
28let inFlight: Promise<void> | null = null
29let ticking = false
30let seen = 0
31
32const TONE: Record<PrTone, { mark: string; color?: string }> = {
33 good: { mark: '●', color: 'green' },
34 warn: { mark: '●', color: 'yellow' },
35 bad: { mark: '●', color: 'red' },
36 quiet: { mark: '○' },
37}
38
39type Where = { url: string; tokenFile: string }
40
41async function token($: EngineInterface, where: Where): Promise<string | undefined> {
42 const fromEnv = await $.env.get('LUBBDUBB_TOKEN')
43 if (fromEnv) return fromEnv.trim()
44 try {
45 return (await $.fs.read(where.tokenFile)).trim()
46 } catch {
47 return undefined
48 }
49}
50
51async function fetchBoard($: EngineInterface, where: Where): Promise<Board | null> {
52 const bearer = await token($, where)
53 const headers = bearer ? { authorization: `Bearer ${bearer}` } : {}
54 const [reply, features] = await Promise.all([
55 $.http.fetch(`${where.url}/api/state?sections=asks,control,fleet,queue,goals`, { headers }).catch(() => null),
56 $.http.fetch(`${where.url}/api/features`, { headers }).catch(() => null),
57 ])
58 if (reply === null || !reply.ok) return null
59 return toBoard(
60 JSON.parse(reply.text) as StateReply,
61 features?.ok ? (JSON.parse(features.text) as FeaturesReply) : null,
62 )
63}
64
65async function adopt($: EngineInterface, after: Board | null): Promise<void> {
66 if (after !== null) {
67 const before = await read($, board)
68 for (const notice of fresh(before, after)) $.ui.toast(`LubbDubb: ${notice.title}`)
69 }
70 await update($, board, () => after)
71}
72
73function poll($: EngineInterface, where: Where): Promise<void> {
74 inFlight ??= (async () => {
75 const after = await fetchBoard($, where)
76 const at = await $.clock.now()
77 await $.store.set(SNAPSHOT_KEY, { at, board: after } satisfies Snapshot)
78 seen = at
79 await adopt($, after)
80 })().finally(() => {
81 inFlight = null
82 })
83 return inFlight
84}
85
86async function tick($: EngineInterface, where: Where): Promise<void> {
87 if (ticking || inFlight !== null) return
88 ticking = true
89 try {
90 const me = await $.session.id()
91 const now = await $.clock.now()
92 const lease = (await $.store.get(LEASE_KEY)) as Lease | undefined
93 const snapshot = (await $.store.get(SNAPSHOT_KEY)) as Snapshot | undefined
94 const leader = lease !== undefined && lease.until > now ? lease.owner : null
95 if (leader === null || leader === me) {
96 await $.store.set(LEASE_KEY, { owner: me, until: now + LEASE_MS } satisfies Lease)
97 }
98 if (leader === me && (snapshot === undefined || now - snapshot.at >= POLL_MS)) {
99 await poll($, where)
100 return
101 }
102 if (snapshot !== undefined && snapshot.at > seen) {
103 seen = snapshot.at
104 await adopt($, snapshot.board)
105 }
106 } finally {
107 ticking = false
108 }
109}
110
111async function setPaused($: EngineInterface, where: Where, paused: boolean): Promise<void> {
112 const bearer = await token($, where)
113 const reply = await $.http.fetch(`${where.url}/api/control`, {
114 method: 'POST',
115 headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) },
116 body: JSON.stringify({ paused }),
117 })
118 if (!reply.ok) throw new Error(`LubbDubb refused (${reply.status})`)
119 await inFlight
120 await poll($, where)
121}
122
123async function act($: EngineInterface, done: string, work: () => Promise<unknown>): Promise<void> {
124 try {
125 const result = await work()
126 if (typeof result === 'object' && result !== null && 'isFilled' in result && result.isFilled === false) {
127 $.ui.toast('LubbDubb: the prompt box did not take it')
128 return
129 }
130 $.ui.toast(done)
131 } catch (err) {
132 $.ui.toast(`LubbDubb: ${err instanceof Error ? err.message : String(err)}`)
133 }
134}
135
136const ago = (since: string): string => {
137 const mins = Math.max(0, Math.round((Date.now() - Date.parse(since)) / 60_000))
138 return mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, '0')}`
139}
140
141async function heartbeat($: EngineInterface): Promise<void> {
142 if ((await read($, board)) === null || !(await read($, isPaneOpen))) return
143 beat = (beat + 1) % LOGO_FRAMES
144 await $.ui.blit({ requestId: PANE, key: LOGO_KEY, columns: LOGO_COLUMNS, rows: LOGO_ROWS, cells: logoCells(beat) })
145}
146
147async function openPane($: EngineInterface): Promise<void> {
148 await $.ui.open({ id: PANE, title: TITLE })
149 await update($, isPaneOpen, () => true)
150}
151
152async function closePane($: EngineInterface): Promise<void> {
153 await $.ui.close({ id: PANE })
154 await update($, isPaneOpen, () => false)
155}
156
157const draft = ($: EngineInterface, text: string) => () => void act($, 'Added to the prompt box', () => $.prompt.fill({ text }))
158
159export const register: Register = (on, options) => {
160 const where: Where = {
161 url: String(options.url ?? 'http://127.0.0.1:4300').replace(/\/$/, ''),
162 tokenFile: String(options.tokenFile ?? '.lubbdubb/cockpit-token'),
163 }
164
165 on('session.start', async ($, e, next) => {
166 await $.command.register({ name: 'board', description: 'List what LubbDubb is waiting on you for' })
167 await $.command.register({ name: 'panel', description: 'Open the LubbDubb panel' })
168 void tick($, where)
169 $.clock.every(TICK_MS, () => void tick($, where))
170 $.clock.every(BEAT_MS, () => void heartbeat($))
171 const panes = await $.ui.panes()
172 await update($, isPaneOpen, () => panes.some(pane => pane.id === PANE))
173 return next(e)
174 })
175
176 on('session.end', async ($, e, next) => {
177 const lease = (await $.store.get(LEASE_KEY)) as Lease | undefined
178 if (lease?.owner === e.sessionId) await $.store.delete(LEASE_KEY)
179 return next(e)
180 })
181
182 on('ui.close', { id: PANE }, async ($, e, next) => {
183 const closed = await next(e)
184 await update($, isPaneOpen, () => false)
185 return closed
186 })
187
188 on('command.run', { command: 'board' }, async $ => {
189 await poll($, where)
190 await update($, isHidden, () => false)
191 const now = await read($, board)
192 if (now === null) return { text: `LubbDubb is not answering at ${where.url}.` }
193 if (now.notices.length === 0) {
194 return { text: now.paused ? 'Nothing waiting on you. The fleet is paused.' : 'Nothing waiting on you.' }
195 }
196 const lines = now.notices.map((n, i) => `${i + 1}. ${n.kind}: ${n.title}`)
197 const after = 'Work through them one at a time with /lubbdubb:next, or in the cockpit.'
198 return { text: [summary(now), ...lines, '', after].join('\n') }
199 })
200
201 on('command.run', { command: 'panel' }, async $ => {
202 await poll($, where)
203 await update($, isHidden, () => false)
204 await openPane($)
205 return { text: 'LubbDubb panel opened.' }
206 })
207
208 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
209 const now = await read($, board)
210 if (e.props.hasSurvey || now === null || (await read($, isHidden))) return next(e)
211 const { Box, Button, Text } = $.ui.resolve(e)
212 const paneOpen = await read($, isPaneOpen)
213 const theirs = await next(e)
214 return (
215 <Box flexDirection="column">
216 <Box key="band" borderStyle="round" borderDimColor paddingX={1} gap={1}>
217 <Text color={now.paused ? 'yellow' : 'green'}>{now.paused ? '■' : '●'}</Text>
218 <Text bold>LubbDubb</Text>
219 {bandParts(now).map((part, i) => {
220 const color = part.tone === undefined ? undefined : TONE[part.tone].color
221 return (
222 <Text key={`part-${i}`} color={color} dimColor={color === undefined}>
223 {`${i === 0 ? '' : '· '}${part.text}`}
224 </Text>
225 )
226 })}
227 {now.notices.length > 0 && (
228 <Button key="next" plain label="Work through asks" onPress={draft($, '/lubbdubb:next')} />
229 )}
230 <Button
231 key="toggle-panel"
232 plain
233 dimColor
234 label={paneOpen ? 'Hide panel' : 'Show panel'}
235 onPress={() =>
236 void act($, paneOpen ? 'LubbDubb panel hidden' : 'LubbDubb panel opened', () =>
237 paneOpen ? closePane($) : openPane($),
238 )
239 }
240 />
241 <Button key="hide" plain dimColor label="Hide" onPress={() => update($, isHidden, () => true)} />
242 </Box>
243 {theirs}
244 </Box>
245 )
246 })
247
248 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
249 const { Box, Text, Button, Link } = $.ui.resolve(e)
250 const now = await read($, board)
251 const width = Math.max(24, e.props.bodyColumns)
252
253 const logo = (alive: boolean) => {
254 if (width < LOGO_MIN_WIDTH) return null
255 if (e.surface === 'terminal') {
256 const { Raster } = $.ui.resolve(e)
257 return <Raster key={LOGO_KEY} columns={LOGO_COLUMNS} rows={LOGO_ROWS} cells={logoCells(alive ? beat : null)} />
258 }
259 const { Svg } = $.ui.resolve(e)
260 return <Svg source={logoSvg(alive)} alt="LubbDubb" width={44} height={44} isInteractive />
261 }
262 const wordmark = (
263 <Text bold>
264 Lubb<Text color="red">Dubb</Text>
265 </Text>
266 )
267
268 if (now === null) {
269 return (
270 <Box gap={2} alignItems="center">
271 {logo(false)}
272 <Text dimColor>{`LubbDubb is not answering at ${where.url}.`}</Text>
273 </Box>
274 )
275 }
276
277 const header = (label: string, count: string, action?: unknown) => (
278 <Box justifyContent="space-between">
279 <Box gap={1}>
280 <Text bold>{label}</Text>
281 <Text dimColor>{count}</Text>
282 </Box>
283 {action}
284 </Box>
285 )
286
287 const row = (r: {
288 id: string
289 mark: string
290 markColor?: string
291 title: string
292 meta: string
293 metaColor?: string
294 onPress?: () => void
295 href?: string | null
296 action?: { label: string; onPress: () => void }
297 }) => {
298 const room = Math.max(8, width - r.meta.length - (r.action ? r.action.label.length + 1 : 0) - 4)
299 const label = clip(r.title, room)
300 return (
301 <Box key={r.id} justifyContent="space-between">
302 <Box gap={1}>
303 <Text color={r.markColor} dimColor={r.markColor === undefined}>
304 {r.mark}
305 </Text>
306 {r.href ? (
307 <Text>
308 <Link href={r.href} label={label} />
309 </Text>
310 ) : r.onPress ? (
311 <Button key={`${r.id}-go`} plain label={label} onPress={r.onPress} />
312 ) : (
313 <Text>{label}</Text>
314 )}
315 </Box>
316 <Box gap={1}>
317 <Text color={r.metaColor} dimColor={r.metaColor === undefined}>
318 {r.meta}
319 </Text>
320 {r.action && <Button key={`${r.id}-action`} plain dimColor label={r.action.label} onPress={r.action.onPress} />}
321 </Box>
322 </Box>
323 )
324 }
325
326 const featureRow = (f: Feature) => {
327 const filled = Math.round((f.delivered / f.total) * BAR)
328 const going = Math.min(BAR - filled, Math.round((f.inFlight / f.total) * BAR))
329 const tally = `${f.delivered}/${f.total}`
330 const room = Math.max(8, width - BAR - tally.length - 5)
331 return (
332 <Box key={`feature-${f.number}`} justifyContent="space-between">
333 <Box gap={1}>
334 <Text color={f.blocked > 0 ? 'red' : undefined} dimColor={f.blocked === 0}>
335 {f.blocked > 0 ? '!' : '·'}
336 </Text>
337 <Button
338 key={`feature-${f.number}-ask`}
339 plain
340 label={clip(f.title, room)}
341 onPress={draft($, `/lubbdubb:feature ${f.number}`)}
342 />
343 </Box>
344 <Box gap={1}>
345 <Text>
346 <Text color="green">{'━'.repeat(filled)}</Text>
347 <Text color="yellow">{'━'.repeat(going)}</Text>
348 <Text dimColor>{'─'.repeat(BAR - filled - going)}</Text>
349 </Text>
350 <Text dimColor>{tally}</Text>
351 </Box>
352 </Box>
353 )
354 }
355
356 const waiting = now.agents.filter(a => a.isWaiting).length
357 const running = now.agents.length - waiting
358
359 return (
360 <Box flexDirection="column" gap={1}>
361 <Box gap={2} alignItems="center">
362 {logo(true)}
363 <Box flexDirection="column" flexGrow={1}>
364 {wordmark}
365 <Box justifyContent="space-between">
366 <Box gap={1}>
367 <Text color={now.paused ? 'yellow' : 'green'}>{now.paused ? '■' : '●'}</Text>
368 <Text bold>{now.paused ? 'Paused' : 'Running'}</Text>
369 <Text dimColor>{`· ${now.agents.length} of ${now.cap} slots`}</Text>
370 </Box>
371 <Button
372 key="pause"
373 hotkey="p"
374 label={now.paused ? 'Resume' : 'Pause'}
375 onPress={() =>
376 void act($, now.paused ? 'Fleet resumed' : 'Fleet paused', () => setPaused($, where, !now.paused))
377 }
378 />
379 </Box>
380 </Box>
381 </Box>
382
383 <Box flexDirection="column">
384 {header(
385 'Needs you',
386 String(now.notices.length),
387 now.notices.length > 0 && (
388 <Button
389 key="next"
390 hotkey="n"
391 variant="primary"
392 label="Work through asks"
393 onPress={draft($, '/lubbdubb:next')}
394 />
395 ),
396 )}
397 {now.notices.length === 0 && <Text dimColor>Nothing waiting on you.</Text>}
398 {now.notices.slice(0, 5).map(n =>
399 row({
400 id: `ask-${n.id}`,
401 mark: n.urgent ? '!' : '·',
402 markColor: n.urgent ? 'red' : undefined,
403 title: n.title,
404 meta: n.kind,
405 onPress: draft($, `/lubbdubb:next ${n.id}`),
406 }),
407 )}
408 {now.notices.length > 5 && <Text dimColor>{` +${now.notices.length - 5} more`}</Text>}
409 </Box>
410
411 {now.features.length > 0 && (
412 <Box flexDirection="column">
413 {header('Features', `${now.features.length} in progress`)}
414 {now.features.slice(0, 5).map(featureRow)}
415 </Box>
416 )}
417
418 <Box flexDirection="column">
419 {header('Pull requests', String(now.prs.length))}
420 {now.prs.length === 0 && <Text dimColor>No open pull requests.</Text>}
421 {now.prs.slice(0, 6).map(pr =>
422 row({
423 id: `pr-${pr.number}`,
424 mark: TONE[pr.tone].mark,
425 markColor: TONE[pr.tone].color,
426 title: `#${pr.number} ${pr.title}`,
427 meta: pr.state,
428 metaColor: TONE[pr.tone].color,
429 href: pr.url,
430 action: { label: 'Review With Me', onPress: draft($, `/pr-assistant:pr walk ${pr.number}`) },
431 }),
432 )}
433 {now.prs.length > 6 && <Text dimColor>{` +${now.prs.length - 6} more`}</Text>}
434 </Box>
435
436 <Box flexDirection="column">
437 {header('Fleet', waiting > 0 ? `${running} running · ${waiting} waiting` : `${running} running`)}
438 {now.agents.length === 0 && <Text dimColor>No agents out.</Text>}
439 {now.agents.map(a =>
440 row({
441 id: `agent-${a.id}`,
442 mark: a.isWaiting ? '○' : '●',
443 markColor: a.isWaiting ? 'yellow' : 'green',
444 title: a.title,
445 meta: `${ago(a.startedAt)} $${a.costUsd.toFixed(2)}`,
446 }),
447 )}
448 </Box>
449
450 <Box flexDirection="column">
451 {header('Up next', String(now.upNext.length))}
452 {now.upNext.length === 0 && <Text dimColor>Queue is empty.</Text>}
453 {now.upNext.slice(0, 5).map(q => row({ id: `q-${q.origin}`, mark: '·', title: q.title, meta: q.origin }))}
454 </Box>
455 </Box>
456 )
457 })
458}
459hooks/board.ts 128 lines1import type { Agent, Board, Feature, Notice, Pr, PrTone, Queued } from '../types'
2
3const LIVE = new Set(['starting', 'running', 'waiting'])
4
5type Ask = { id: string; kind: string; title: string; standing: boolean; focusRank: number; urgency?: string }
6
7type WirePr = {
8 number: number
9 title: string
10 ciStatus: 'passing' | 'failing' | 'pending' | 'unknown'
11 unresolvedComments: unknown[]
12 approved?: boolean
13 mergeableState?: 'dirty' | 'behind' | 'blocked' | 'clean' | 'unknown'
14 state?: 'open' | 'merged' | 'closed'
15}
16
17export type StateReply = {
18 asks?: Ask[]
19 control?: { paused: boolean; cap?: number }
20 agents?: { id: string; taskId: string; status: string; costUsd: number | null; startedAt: string }[]
21 tasks?: { id: string; title: string }[]
22 upcoming?: { items: { origin: string; title: string; status: string }[] } | null
23 world?: { pullRequests: WirePr[] }
24 refUrls?: Record<string, string>
25}
26
27export type FeaturesReply = {
28 features?: {
29 number: number
30 title: string
31 counts: { delivered: number; inFlight: number; settled: number; total: number }
32 briefing: { blockingTotal: number }
33 }[]
34}
35
36export const clip = (text: string, max: number): string => {
37 const line = text.split('\n')[0] ?? ''
38 return line.length > max ? `${line.slice(0, Math.max(1, max - 1))}…` : line
39}
40
41export const safeHref = (url: string | undefined): string | null =>
42 url !== undefined && /^https:\/\/[!-~]+$/.test(url) && !url.includes('@') && url.length <= 2048 ? url : null
43
44export function prState(pr: WirePr): { state: string; tone: PrTone } {
45 const threads = pr.unresolvedComments.length
46 if (pr.mergeableState === 'dirty') return { state: 'conflicts', tone: 'bad' }
47 if (pr.ciStatus === 'failing') return { state: 'CI failing', tone: 'bad' }
48 if (threads > 0) return { state: `${threads} ${threads === 1 ? 'thread' : 'threads'}`, tone: 'warn' }
49 if (pr.ciStatus === 'pending') return { state: 'CI running', tone: 'quiet' }
50 if (pr.approved && pr.mergeableState !== 'blocked') return { state: 'ready to merge', tone: 'good' }
51 if (pr.mergeableState === 'behind') return { state: 'behind base', tone: 'warn' }
52 return { state: 'in review', tone: 'quiet' }
53}
54
55export function toBoard(state: StateReply, board: FeaturesReply | null): Board {
56 const titles = new Map((state.tasks ?? []).map(t => [t.id, t.title]))
57 const notices: Notice[] = (state.asks ?? [])
58 .filter(a => a.standing)
59 .sort((a, b) => a.focusRank - b.focusRank)
60 .map(a => ({ id: a.id, kind: a.kind.replace(/_/g, ' '), title: clip(a.title, 80), urgent: a.urgency === 'now' }))
61 const features: Feature[] = (board?.features ?? [])
62 .map(f => ({
63 number: f.number,
64 title: f.title,
65 delivered: f.counts.delivered + f.counts.settled,
66 inFlight: f.counts.inFlight,
67 blocked: f.briefing.blockingTotal,
68 total: f.counts.total,
69 }))
70 .filter(f => f.total > 0 && f.delivered < f.total)
71 const prs: Pr[] = (state.world?.pullRequests ?? [])
72 .filter(pr => (pr.state ?? 'open') === 'open')
73 .map(pr => ({
74 number: pr.number,
75 title: pr.title,
76 ...prState(pr),
77 url: safeHref(state.refUrls?.[`pr:${pr.number}`] ?? state.refUrls?.[`#${pr.number}`]),
78 }))
79 const agents: Agent[] = (state.agents ?? [])
80 .filter(a => LIVE.has(a.status))
81 .map(a => ({
82 id: a.id,
83 title: titles.get(a.taskId) ?? a.taskId,
84 isWaiting: a.status === 'waiting',
85 costUsd: a.costUsd ?? 0,
86 startedAt: a.startedAt,
87 }))
88 const upNext: Queued[] = (state.upcoming?.items ?? []).map(q => ({ origin: q.origin, title: q.title, status: q.status }))
89 return {
90 notices,
91 features,
92 prs,
93 agents,
94 upNext,
95 paused: state.control?.paused ?? false,
96 cap: state.control?.cap ?? 0,
97 }
98}
99
100export type Part = { text: string; tone?: PrTone }
101
102export function bandParts(board: Board): Part[] {
103 const count = board.notices.length
104 const urgent = board.notices.filter(n => n.urgent).length
105 const attention = board.prs.filter(pr => pr.tone === 'bad' || pr.tone === 'warn').length
106 const parts: (Part | false)[] = [
107 board.paused && { text: 'fleet paused', tone: 'warn' },
108 { text: `${board.agents.length}/${board.cap} agents` },
109 count > 0
110 ? { text: `${count} ${count === 1 ? 'ask' : 'asks'}${urgent > 0 ? ` (${urgent} blocking)` : ''}`, tone: urgent > 0 ? 'bad' : 'warn' }
111 : { text: 'no asks' },
112 attention > 0 && { text: `${attention} ${attention === 1 ? 'PR needs' : 'PRs need'} attention`, tone: 'warn' },
113 ]
114 return parts.filter((p): p is Part => p !== false)
115}
116
117export function summary(board: Board): string {
118 return bandParts(board)
119 .map(p => p.text)
120 .join(' · ')
121}
122
123export function fresh(before: Board | null, after: Board): Notice[] {
124 if (before === null) return []
125 const seen = new Set(before.notices.map(n => n.id))
126 return after.notices.filter(n => !seen.has(n.id))
127}
128hooks/logo.ts 86 lines1const ART = [
2 '.......RR.......',
3 '.......##.......',
4 '..############..',
5 '.##############.',
6 '.##############.',
7 '.###oo####oo###.',
8 'R###oo####oo###R',
9 'R##############R',
10 'R##############R',
11 'R##############R',
12 '.##############.',
13 '.##############.',
14 '.##############.',
15 '.##############.',
16 '..############..',
17 '................',
18]
19
20const BEAT: readonly (readonly [number, number])[] = [
21 [3, 11], [4, 11], [5, 11], [6, 10], [7, 9], [8, 10], [8, 11], [8, 12], [9, 11], [10, 11], [11, 10], [12, 11],
22]
23const PULSE = 3
24
25const INK: Record<string, number | null> = {
26 '.': null,
27 o: null,
28 '#': 0xd4d4d8,
29 R: 0xe5484d,
30 r: 0x5a2a2c,
31 T: 0x55555c,
32}
33const DEFAULT = 0x01000000
34const UPPER = 0x2580
35const LOWER = 0x2584
36
37export const LOGO_COLUMNS = ART[0]!.length
38export const LOGO_ROWS = ART.length / 2
39export const LOGO_FRAMES = BEAT.length + PULSE
40
41/** The robot at `frame` of its heartbeat, or flat-lined for `null`, packed as a Raster's `cells`. */
42export function logoCells(frame: number | null): string {
43 const grid = ART.map(row => row.split(''))
44 if (frame === null) {
45 for (let x = 3; x <= 12; x++) grid[11]![x] = 'T'
46 } else {
47 const at = frame % LOGO_FRAMES
48 BEAT.forEach(([x, y], i) => (grid[y]![x] = at - PULSE < i && i <= at ? 'R' : 'T'))
49 if (at >= LOGO_FRAMES / 2) grid[0]![7] = grid[0]![8] = 'r'
50 }
51 const words = new Uint32Array(LOGO_COLUMNS * LOGO_ROWS * 3)
52 for (let row = 0; row < LOGO_ROWS; row++) {
53 for (let x = 0; x < LOGO_COLUMNS; x++) {
54 const top = INK[grid[row * 2]![x]!] ?? null
55 const bottom = INK[grid[row * 2 + 1]![x]!] ?? null
56 const cell = [
57 top === null && bottom === null ? 0x20 : top === null ? LOWER : UPPER,
58 top ?? bottom ?? DEFAULT,
59 top === null ? DEFAULT : (bottom ?? DEFAULT),
60 ]
61 words.set(cell, (row * LOGO_COLUMNS + x) * 3)
62 }
63 }
64 const bytes = new Uint8Array(words.buffer)
65 let binary = ''
66 for (const byte of bytes) binary += String.fromCharCode(byte)
67 return btoa(binary)
68}
69
70const MOUTH = '34,80 46,80 51,71 57,89 62,80 70,80 73,76 76,84 79,80 86,80'
71
72/** The same robot for the surfaces that draw SVG, animated while `alive`. */
73export function logoSvg(alive: boolean): string {
74 const mouth = alive ? MOUTH : '34,80 86,80'
75 const pulse = alive
76 ? `<polyline class="p" points="${MOUTH}" fill="none" stroke="#e5484d" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>`
77 : ''
78 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"><style>
79.h{fill:#111}.s{stroke:#111}.t{stroke:#3a3a3e}.e{fill:#fff}
80@media (prefers-color-scheme:dark){.h{fill:#d4d4d8}.s{stroke:#d4d4d8}.t{stroke:#55555c}.e{fill:#1e1e1e}}
81.a{animation:b 1.2s infinite}.p{stroke-dasharray:30 120;animation:m 1.2s linear infinite}
82@keyframes b{0%,70%,100%{opacity:1}35%{opacity:.25}}@keyframes m{from{stroke-dashoffset:30}to{stroke-dashoffset:-110}}
83@media (prefers-reduced-motion:reduce){.a,.p{animation:none}.p{stroke-dasharray:none}}
84</style><rect x="12" y="56" width="10" height="20" rx="4" fill="#e5484d"/><rect x="98" y="56" width="10" height="20" rx="4" fill="#e5484d"/><line class="s" x1="60" y1="34" x2="60" y2="22" stroke-width="5" stroke-linecap="round"/><circle${alive ? ' class="a"' : ''} cx="60" cy="18" r="6" fill="#e5484d"/><rect class="h" x="20" y="34" width="80" height="64" rx="18"/><circle class="e" cx="44" cy="58" r="7"/><circle class="e" cx="76" cy="58" r="7"/><polyline class="t" points="${mouth}" fill="none" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>${pulse}</svg>`
85}
86types/index.d.ts 28 lines1export type Notice = { id: string; kind: string; title: string; urgent: boolean }
2
3export type Feature = { number: number; title: string; delivered: number; inFlight: number; blocked: number; total: number }
4
5export type PrTone = 'good' | 'warn' | 'bad' | 'quiet'
6
7export type Pr = { number: number; title: string; state: string; tone: PrTone; url: string | null }
8
9export type Agent = { id: string; title: string; isWaiting: boolean; costUsd: number; startedAt: string }
10
11export type Queued = { origin: string; title: string; status: string }
12
13export type Board = {
14 notices: Notice[]
15 features: Feature[]
16 prs: Pr[]
17 agents: Agent[]
18 upNext: Queued[]
19 paused: boolean
20 cap: number
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 lubbdubb: { board: Board | null; isHidden: boolean; isPaneOpen: boolean }
26 }
27}
28