SLOPSHOPPER

lubbdubb

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

newpanebandcommandtoastnetwork
★ 1v0.0.0MITupdated 2026-10-05AdamAwan/LubbDubb/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lubbdubb
│ ┃ LubbDubb ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀ LubbDubb is not answering │ ┃ ▄▀▀▀▀▀▀▀▀▀▀▀▀▄ http://127.0.0.1:4300. ⏺ 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 │ │ › /board │ ⎿ lubbdubb: LubbDubb is not answering at http://127.0.0.1:4300. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · LubbDubb
▀▀ LubbDubb is not answering at http://127.0. ▄▀▀▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▄▄▀▀▀▀▄▄▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀
README

<img src="docs/brand/logo.svg" alt="LubbDubb" width="120">

LubbDubb

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.md is 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.md is 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.md is how it got this way.


The pulse

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.

What it does

Grouped by where in the loop it sits. Each line links to the spec that owns it.

Taking work in

FeatureWhat it is
Opt-in watchingOne ${labelPrefix}-watch tag decides what is acted on, on issues and pull requests alike. Nothing outside it is touched. → [06][s06]
Two providersGitHub and Azure DevOps behind per-capability seams, plus a fake provider the whole suite and the demo run on. → [15][s15]
Tracker statesWhere 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 orderPriority labels weight pickup; the ranked plan ships as Up next with a cut-line at current headroom, re-orderable by you. → [05][s05]
Tickets boardEvery 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]
AttachmentsImages attached to a brief follow the issue to whichever agent works it, stored outside every worktree. → [12][s12]

Deciding

FeatureWhat it is
A rule pipelineTwo 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 vocabularyThe dispatcher can only ever ask for one of eleven validated actions; anything malformed is rejected and audited, never executed. → [05][s05]
Per-check CI policyWhat 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 logExecuted, 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 capsPer-origin attempt caps and cooldowns, so a dispatch that keeps failing escalates instead of looping. → [05][s05]

Doing the work

FeatureWhat it is
Agents in worktreesA 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 channelAn MCP server agents call back on — read the world, raise what they learned, open a pull request, report a check. → [11][s11]
A permission backstopAn agent hitting a command outside the allow-list asks you rather than hanging. → [11][s11]
Models per ruleNamed 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 schedulesAn 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 recoveryAgents orphaned by a restart are parked, and the pulse is held, until you restore, requeue or remove each one. → [10][s10]
Live transcriptsClick an agent and read what it is doing, type into it, and see what it produced mid-run. → [10][s10], [12][s12]

Pull requests

FeatureWhat it is
Health predicatesFailing CI, behind or conflicting with its base, unhandled review threads, ready to merge — one agent per branch, top concern first. → [07][s07]
The fleet reviewOff 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 reviewEvery unhandled thread goes to one agent, replied to through the harness — signed, recorded, and resolved on the provider's own threads. → [07][s07]
StacksA 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 youA pull request somebody assigned you, who asked, and how long it has been sitting there. → [17][s17]

The funnel, per goal

FeatureWhat it is
Goal appraisalIs 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]
PlanningOne pull request, or a dependency-chained decomposition into parts each with its own branch and scope — or "this is already done". → [08][s08]
Plan approvalAlways. A plan carries risks, scope-outs and how anyone will know it worked, and can be discussed with a conversational planner. → [08][s08]
AssessmentAsked of what was delivered, not of the agent's confidence. Its no arm replans, adds a part, or escalates. → [08][s08]
ValidationChecks that can only be answered by running the delivered thing, written for a person — or handed to your own Claude Code. → [20][s20]
RetrospectiveOne write-up per delivered goal, from the shared scratchpad and the harness's own record of what it cost. → [13][s13]
Close-outThe 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]

After it lands

FeatureWhat it is
EnvironmentsOff by default. The commit each PR landed as, and whether each environment has it yet — asked with your own command, three-valued. → [24][s24]
ArrivalsArriving 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 watchA 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]
ObstaclesWhat 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 poolThe distance above fleet: one namespace per fleet in a shared repository, with a corroboration model and a digest. → [28][s28]

Watching the harness itself

FeatureWhat it is
Needs youOne rail: escalations, plan approvals, outbound proposals, permission requests, config health, and the obligations a delivery leaves. → [17][s17]
InsightsWhat 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 runwayWhether there is work left for the fleet, measured in fleet time — and whether the reason there is not is you. → [25][s25]
Config healthOne 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 logThe one path every caught failure funnels through: persisted, mirrored to stderr, streamed to the cockpit. → [18][s18]
Self-updateThe harness watches its own build, drains, and hands off to a supervisor that replaces it. → [21][s21]
Local runsThe 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]
PetsA vivarium at the foot of the rail. Your actions drop eggs; the eggs hatch. It gates nothing. → [22][s22]

The flow of work

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

The standard steps

StepWhat happens
IntakeEvery 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 workA 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 workAn 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 gatesThe 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.
MergeA 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.
DeliverA 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 outNothing closes the ticket. A standing obligation with your name on it is filed, and settles itself once the tracker stops listing the item open.
ArrivalWhere 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.

The gates that carry the loop

Each is a decision something has to make, not a step that always passes.

  • Enough information to proceed rejects a goal nothing can act on, before an agent spends itself discovering that. Refusal is not silent — it says what is missing, on the ticket — and it is not permanent: the hold ends when the goal text changes, or when anyone comments.
  • Plan accepted is where you see the shape of the work before it happens. There is no switch for it: a plan that started itself can only be undone by a replan, which is strictly worse.
  • Quality gates are a set, not a list — tests, static analysis, pipeline health, human review, and the fleet's own review where it is on. The classification is per check, and the third reading is the one that matters: red, but not ours holds and says why, rather than sending an agent at a wall.
  • Goal achieved is asked of the delivered work. Its no arm proposes a replan, a follow-up part, or escalates — depending on what the assessment says fell short.

Priorities when headroom is scarce

The dispatcher ranks every candidate, then applies the concurrency cut. Roughly, highest first:

  1. An operator queued a job from the cockpit — takes the next free slot.
  2. A PR with problems: failing CI, a stale or conflicting base, an unhandled review comment.
  3. A PR that is ready to merge.
  4. Planning, approval, appraisal and assessment for issues.
  5. Plan parts, then fresh issue pickup.

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.

Where you are in the loop

The harness owns the loop; you own the verdicts. You are asked when — and only when — a decision is genuinely yours:

  • Needs you collects escalations, plan approvals, outbound-act proposals, permission requests from agents that hit a command outside the allow-list, the config checks, and what a delivered goal still owes you — a validation sheet to run, a ticket to close.
  • Nothing side-effectful leaves without a human, save two standing authorities that are yours: a stack landing you clicked over named pull requests, and 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.
  • A restart never decides for you. Agents orphaned by a crash or shutdown are parked, and the heartbeat is held until you restore, requeue or remove each one.

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.

Getting started

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

One click: the Claude Code plugin

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

Configuration

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:

KeyDefaultWhy it matters
repoRootthe directory you launch inThe 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.

Source 4 files
hooks/register.tsx 459 lines
1import { 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}
459
hooks/board.ts 128 lines
1import 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}
128
hooks/logo.ts 86 lines
1const 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}
86
types/index.d.ts 28 lines
1export 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