SLOPSHOPPER

lampboard

LampBoard's companion: tells the LampBoard panel on this Mac each session's context, cost and rate limits, asks LampMaster with /lampmaster, writes a handoff…

newpanebandspinnerrowsguard
★ 1v1.15.0MITupdated 2026-10-09marmyx77/lampboard/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lampboard
│ ┃ LampBoard ✕ › fix the failing auth test and add an audit log call │ ┃ No lamps: LampBoard is closed, or quiet. │ ⏺ 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 │ │ › /lampmaster │ ⎿ lampboard: Ask LampMaster what your other sessions know: /lampma │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · LampBoard
No lamps: LampBoard is closed, or quiet.
README

LampBoard

A floating column of traffic lights that tells you, at a glance, what state your coding sessions are in — Claude Code and Codex, in VS Code, in a terminal, in a desktop app, on another machine. One traffic light per project, and beside each one a ring saying how much room that conversation has left. You click it and you're in that window, or that tab.

It comes from a concrete problem: with a dozen editor windows open, finding out which one is waiting for an answer and which one is still working means going through all of them.

The two questions it answers that a list of sessions does not: how much context is left in there, and has it really finished — a turn can hand back control while three background agents keep working for another forty minutes.

<img src="docs/images/panel.png" width="340" alt="The panel: six projects, six states, and a ring on each row showing how full its context window is.">

A project in each state, a ring on every row, and under each name what that session is doing now. The letter in the ring is the model — Sonnet, Opus, Haiku, and G for the GPT model of the Codex row at the bottom. The bar at the top counts what waits for you — sessions asking, answers to read, turns that stopped — and ends with LampMaster's star; a permission the panel holds opens under its own row, with Allow and Deny. Under the rows, the usage left on the account. That picture is not a screenshot somebody took: Scripts/make-screenshots.sh starts the demo — the real app on a temporary home, playing a script of invented projects, reachable only that way — and captures its window, so the image can never contain anybody's real work and never falls behind the panel it shows. The band at the top says so on the picture itself.

Need to get your hands dirty? The complete technical documentation lives in docs/. If you only have time for one file, read docs/07 traps: it is the catalogue of defects already paid for, and each one is a day you won't have to spend again.

The states

ColorStateMeaning
🟠 blinking amberneeds you (awaiting)Claude is waiting for an answer from you — a permission, or a dialog opened by an MCP server. It is the only state that blocks the work, and the only one that blinks.
🟢 greendone (ready)The turn has finished: there is an answer to read.
🔴 solid redstopped (failed)The turn stopped without producing anything: rate limit, overload, authentication error.
🟡 yellowworkingClaude is processing or running tools.
🔵 soft bluepaused (waiting)The turn is over, but something Claude started is still running — a shell, a monitor on a CI run, a subagent — and will wake it. Nothing for you to do yet.
◯ grey ringresting (idle)Resting: the session is at rest. Nothing to read. After twelve hours at rest, a row folds into one Resting line at the foot of the column.

The table is in order of urgency; the column is not — rows keep the order you gave them, and a state lights a row up where it is. Green does not mean "all good", it means "there is something you haven't seen yet". Clicking the traffic light returns the session to a grey ring: you've seen it.

Blue is the one state that says neither "working" nor "done". Before it existed, a session that had stopped and was waiting an hour for a CI run stayed yellow the whole hour — and yellow reads as "Claude is thinking". The tooltip says what the row is waiting on: waiting on monitor ×2, shell.

Red is a turn that failed and nothing else. A session at rest used to be the same red, dimmed, and on the dark panel it read as broken: reading a green answer turned its row "red". At rest is a grey ring now (D124). failed sits below ready because a ready answer is consumed at once, whereas there is nothing you can do about a rate limit until it expires.

Two shapes say what the colour cannot. A dashed yellow light is a session at work that has sat on one tool for a quarter of an hour or more: a long build, or a command waiting on input. A hollow light is a session the helper does not speak for, while other sessions have it: its colour comes from the hooks alone, and what needs the helper is not there for it, usually because it started before the helper was installed. The legend, What the lights mean…, draws both (D115).

The ring beside the light

Every row carries a second, smaller ring: the arc is how much of the model's context window that session has spent, and the letter in the middle is the model family — Opus, Sonnet, Haiku, Fable, Mythos, G for the GPT family Codex runs, n for one this build has no window for. Monochrome, deliberately: six states already own the colour here. The letter is also what tells two rows in the same project apart when one is Claude Code and the other is Codex, which costs no pixels and no second glyph.

Three different silences get three different marks. A dashed circle means nothing has been read from that session yet. A paler arc means the reading is a floor — at least this much — because only a reply carries a token count and anything loaded since is invisible. A solid circle with a dimmed letter and no arc means the figure is void: the session was compacted after that reading, so it describes a conversation that no longer exists.

The denominator is the whole window, and for Claude Code that was measured rather than assumed — see 04-decisions, D30 and Scripts/measure-compaction.py.

For Codex it is not measured at all, and that is a fourth mark: declared. Codex writes model_context_window into the same record as the token count, so a Codex percentage rests on nothing of ours — no table, no calibration, nothing a vendor can invalidate without telling anybody. The card says so.

With the helper installed (below), a Claude Code session counts its own context and the helper passes the figure on: reported, the number Claude Code's status line shows. Nothing read from the transcript afterwards replaces it.

The marks beside the name

At most a few, and only when they say something:

  • ⟳43m, on a row that waits for you: how many more minutes the prompt cache stays warm. An answer given now rereads the conversation cheaply. The figure comes from the transcript, which says whether the cache was written for five minutes or an hour (D100).
  • ⚠, in red: two live sessions wrote the same file in the last two hours. The tooltip names the file and the other session (D99).
  • ✉3, in teal: three answers since you last looked. One answer is what green already says, so the mark appears from two on, on a session woken again and again, by its own background work or by another session's message, with nobody reading. Clicking the row clears it, and so does typing a prompt into it (D108).

Before two sessions write the same file. The ⚠ says it after the fact. With the helper, a session about to edit a file that another live session wrote in the last two hours is stopped first, even when it would have edited on its own: it asks you, and a line at its foot says why, such as "notes.txt was written by “api” 12 minutes ago: check with it before changing it". In auto mode the question goes to the mode's own judge rather than to you, so there the line is all you get (D113).

Two harnesses, one row

LampBoard watches Claude Code and Codex. Both get the same row: the same dot with the same six meanings, the same ring meaning the same thing, the same slot number. What differs is not the drawing — it is what a row is able to promise, and the card says so rather than leaving you to find out.

Claude CodeCodex
Where its sessions live~/.claude~/.codex
Context ringmeasured denominator, with a confidencewindow declared by the harness
Usage leftnot on disk anywherein the card
Amber says what is being askedno, by our choice: see belowyes, Bash: git push origin main
Red, when a turn failsyesnever: Codex publishes no error event at all
Blue, while background agents workyesyes

Which surfaces, and how far

A harness is not a surface. The same agent reaches this machine through several programs, and they do not all speak to us the same way, so the honest unit is the surface rather than the vendor. Measured on 30 August 2026, and a line moves only when a test of that surface moves it.

SurfaceDiscoveredLivenessStateFocusEvidence
Claude Code, VS Code extensionyeswindow lockfullwindowunit · end-to-end · live
Claude Code, terminalyessession file and pidfullterminal seatunit · end-to-end · live
Claude Code, over the tunnelyesprobe on the far sidefullRemote-SSH windowunit · end-to-end · live
Claude Code, desktop app — local sessionindex and transcriptthe app is runningderived from the transcript, never red, never amberraises the appunit · end-to-end
Claude Code, desktop app — cloud sessionno—
Codex, CLIopen rolloutopen descriptorhooks, never redterminal seatunit · end-to-end · live
Codex, VS Code extensionopen rolloutopen descriptorhooks, never redwindowunit · live discovery
Codex, ChatGPT appopen rolloutopen descriptorpresence onlyraises the appunit · live discovery

The last column says what kind of proof stands behind the line, because one word for all of them was doing too much work:

  • unit — the decision is covered in the domain suite, and a mutation of it turns that suite red.
  • end-to-end — the shipping binary, against a fixture home: files are written where the real thing writes them, and the row has to appear on its own.
  • live — a real session of that surface on this machine, clicked, with the window watched.
  • live discovery — the sessions were found and classified on this machine, from real processes. The click was not exercised on that surface.

The end-to-end suite proves the command line surface with a copy of /usr/bin/tail named codex, which is honest about discovery and says nothing about raising a ChatGPT window. That is why the last two lines say what they say.

Six of those lines are worth the words.

Claude Desktop has two kinds of session, and only one of them is here. A cloud session runs on Anthropic's servers: its transcript never touches this disk, its hooks are a documented open gap (anthropics/claude-code#40495, three root causes, open since March), and no probe tried — descriptor, socket, network route, session file — found anything at all. A local session runs here, as a child of the application, and writes exactly the files every terminal session writes. The application says which is which itself, in resolvedFolderKinds, and that answer is taken rather than guessed at.

A Claude Desktop colour is derived, not reported. Those sessions run with a CLAUDE_CONFIG_DIR of their own and never read the hooks on this machine, so nothing announces what they are doing. What is left is the transcript, and what a transcript can say is whether a turn is running or has ended. It cannot say a session is waiting for a permission — no record marks that pause — so those rows never go amber, and never red. A limit you are told is a limit.

Presence there is not the agent process. That process lives one turn: the application starts it to answer and removes its session file when it exits. Measured here, a conversation whose last word landed at 22:44:38 left an empty sessions directory stamped 22:44 — so a row built on it appeared while the model worked and vanished at the moment there was an answer to read. The row lives on the index and the transcript, and goes when the conversation is archived, when the app is quit, when it has been silent for twelve hours, or when you delete the conversation — which takes its folder off the disk, and the row with it.

A click opens that very conversation in the Claude app's Code tab. The app files each Code conversation under an id of its own, beside the id every hook carries, and has a link that opens it: claude://code/continue?session=local_<id>. A click finds that id in the app's own index and opens the link. Measured on the test Mac, the app was on one conversation, and clicking the other's row brought that one up (D107). A conversation the app does not file that way still only raises the app.

A Codex session is found, not announced. Codex inside the ChatGPT app registers our hooks, marks them trusted, runs a whole session and sends nothing at all: measured here, with eight events configured and not one line in the log. So the evidence runs the other way. A live codex process holds its rollout open; that file says which session and which folder; the binary behind the pid says which surface. Nothing has to be sent to us, and the folder of a row found this way is never taken from anything that was: a hook arriving on the unauthenticated route may move such a row's colour and nothing else. That is the property the old admission gate had and the reason it could not simply be widened.

An open rollout is a conversation loaded, not a model working. The editor extension was seen holding one open whose last record was a year old. A session found this way and never heard from carries presence and focus and no colour it cannot prove; the states come from hooks, where hooks arrive.

A cloud session in the desktop app is still not something we can see. It runs inside an isolated Linux VM with an address of its own. A hook fired in there would look for this panel on the VM's own loopback, and nothing we can install reaches inside. That remains a declared limit rather than a missing feature.

A terminal Codex is raised by its ancestry, not by its folder. The same binary runs in Terminal, Ghostty, tmux and VS Code's own terminal, so the executable proves which program it is and not where it is being typed. Opening the folder's editor window would be the convincing wrong answer, so the click asks the question a terminal row already answers — whose ancestry is this, and what tab does that application select — starting from the process holding the rollout open, because Codex writes no session file. It used to stop there and say the row could not be raised, which was honest and useless.

Two of those rows are worth the words.

Codex has no error event. Not StopFailure, not Error, not TurnFailed — checked against its published event table. A turn that fails simply stops emitting hooks, and silence does not distinguish a crash from a model thinking for a long time. So a Codex row never turns red, and every card on a Codex row carries the line Codex reports no failures: a turn that fails stops speaking. A limit you are told is a limit; a limit you meet by trusting a green row that was never going to turn red is a defect with a good explanation.

Codex says what it is asking for, and that is our doing rather than a difference between the two agents. Both publish a PermissionRequest carrying the tool and its arguments. What differs is which hooks LampBoard is willing to register.

On Claude Code the amber state already arrives through Notification, which is passive: the shipped binary builds it as Claude needs your permission to use Bash and carries no arguments at all. So amber costs nothing there, and we decline to sit in the approval path for the sake of the extra sentence. Codex has no Notification, so refusing the same hook would not cost a sentence, it would cost the amber state itself. It is registered there, and the command comes with it.

Only five fields are ever shown: command, file_path, path, url, description. A patch's input carries the contents of the file being written, and this panel floats above screens that get shared.

Installing Codex's hooks

Connecting — from the welcome window, Settings, or lampboard install-hooks — installs both, wherever both are present. Then one step that cannot be automated:

Codex will not run a hook it has not been told to trust, and says nothing when it declines. Open Codex, run /hooks, approve the entry. Until you do, the file is correct, the events never fire, and there is no error anywhere to explain it.

That sentence is printed by the installer for the same reason it is here: finding it out cost an hour.

Hovering a row

The panel draws its own tooltips, because AppKit's only appear in a window that is key and this one never is. Resting on a row opens a card: the name, the state, the machine and the folder underneath a name you chose, the exact figure with the tokens behind it, the model with its version, what the row is waiting on, its slot and the command that opens it, what the conversation has cost when the helper is installed (summed over a project's conversations, at list price), and — on a group — what each session in it is doing.

While the pointer is on a row, a folder glyph appears between the timestamp and the drag handle: it opens a Finder window inside that project. ⇧+click does the same, and so does Show in Finder in the row's menu. None of the three appears on a session that lives on another machine.

Under the rows

A hairline, then four controls. On the left, in the lights' own column, the one that narrows the panel to a strip of lights and widens it again, and beside it the one that sends the panel to the menu bar or brings it back. On the right, in the drag handles' column, the legend — what the six colours and the two rings mean, with a live count of each — and the menu.

Usage left: how much of your plan remains

Off by default. Turned on, a strip appears at the foot of the column with three bars: the rolling five-hour window Claude Code calls the session limit, the week, and one model's own weekly cap — Fable 5.1 on the account this was written against. The same figures /usage shows. They belong to the account, not to a row, which is why they are drawn once, underneath, and in bars rather than rings: the ring beside each row already means how full that conversation's context window is, and a second ring meaning something else would read as the same measurement about a different subject.

When the window runs out before it resets. The panel remembers the session window's readings, and at the pace of the last hour it works out when the window reaches a hundred. When that would happen before the window resets, the time it runs out takes the reset's place at the end of the line, in orange, such as ~11:40. Hovering it shows the sentence: "runs out ~11:40, resets 13:10". Under ten minutes of readings, or with no climb in them, it says nothing rather than guess (D111).

Lowering a session until the reset. A row whose session runs on Opus or Sonnet offers, in its menu, Use Sonnet until the window resets (13:10), or Haiku for a Sonnet session. Chosen, the helper runs that session one model lower from its next turn until the window resets, then lets it go back on its own model. Choosing the item again gives the model back sooner. The session in focus is never offered. Nothing is lowered without your click (D112).

One group of bars per account, and the machines on other machines are asked too. You can be signed in differently in different places — measured across the two machines this was built on, an organization account on the laptop and a personal one on the build box, on two different plans with two different-sized allowances, with most of the work happening on the second. A single unlabelled bar there would not be incomplete, it would be wrong: it would look like your remaining room while the sessions spending a different allowance sat a few rows above. So each group is named, the name appears only when there is more than one account, and two machines signed into the same account draw one group. Each node is asked on the node — only the three percentages come back over the tunnel, never the credential.

With the helper, no request at all. A Claude Code session counts its account's session and week windows on every response, and the helper passes them on: with it installed the strip draws those two bars for this Mac with the switch off, since nothing leaves the Mac to get them. They are taken only from sessions on Claude Code's default configuration — a session with a CLAUDE_CONFIG_DIR of its own may be another account — and a session does not say whose account it is, so the group is labelled by the machine unless the request below has named it. Turned on as well, the request stays the reserve and the only source of the model's own weekly cap; whichever reading is newer draws the session and week bars (D67).

This is the only thing lampboard sends anywhere apart from the update check, and the reason it has a switch. Everything else here reads files that are already on your Mac. This asks api.anthropic.com, roughly every two and a half minutes, signed with the token Claude Code already keeps in your keychain. The panel says so before the first request leaves. The token is read through /usr/bin/security, the tool Claude Code writes it with and the one the keychain item trusts, so macOS asks nothing — the first version read it through the Security framework, and every launch of a new copy brought up the keychain dialog (D52).

It borrows that token and never renews it. The keychain blob also holds a refresh token, and there is deliberately no code here that reads it: refresh tokens are commonly rotated when spent, so minting a new one could invalidate Claude Code's own copy and sign you out of the tool this panel exists to watch. The consequence is visible and intended — a Mac with no Claude Code session in eight hours has an expired token, and the strip goes quiet until Claude Code refreshes it. That is the stretch in which nobody is spending any allowance.

Claude Code keeps its own copy of these figures in ~/.claude.json, free to read and needing no credentials, and it is deliberately not used. Measured on 20 September 2026: that cache said the five-hour window was at 0% and the week at 6% while the account was really at 9% and 15% — fifteen hours out of date. A number nobody can tell is stale is worse than no number.

In the menu bar

The panel can live in one of two places, and a lamp can sit in the menu bar in either case.

Its own window, above everything, staying where you put it. That is what it has always been and what it still is by default.

Under a lamp in the menu bar, opening and closing when you click it, the way a menu does. The button under the rows moves it either way, and so does Panel lives in in Settings › Panel. Coming back, the panel returns to the corner you last left it in rather than to wherever the drop-down was hanging.

The lamp is a separate switch (*Show a lamp in the menu

Source 3 files
hooks/register.js 729 lines
1// LampBoard's companion mod: tells the LampBoard panel on this Mac what only
2// the session knows — its context as Claude Code counts it, what it has cost,
3// the account's rate-limit windows, where it draws and why it ended.
4//
5// It reads nothing of the conversation unless the person asks it a side question
6// from the panel (below) — of a running tool only its name and
7// the first line of its shell command or its file path, to say which one a
8// stuck session is on; the panel masks what looks like a secret — writes
9// nothing, runs nothing, and
10// talks to one address: 127.0.0.1, on the port the panel wrote, with the token
11// the panel wrote, both under ~/.lampboard.
12// When the panel is not there it does nothing, silently: a session must never
13// wait on, or hear about, a dashboard. The panel is found from HOME, never from
14// LAMPBOARD_HOME, which a project's settings could point anywhere.
15//
16// One exception, asked for by the person: `/lampmaster <question>` sends the
17// question they typed, and the session's folder, to the same address, and
18// prints LampMaster's answer (D71).
19//
20// And one decision, when the person has switched it on in LampBoard: a call
21// Claude Code would put to its permission dialog is put to the panel first,
22// and the panel's allow or deny stands; unanswered in 55 seconds, or with the
23// switch off, the dialog comes as it always did (D80). For that one the mod
24// reads a second file the panel wrote, its permission key, sends it to nobody,
25// and proves it holds it; the panel's answer counts only signed with it.
26//
27// And a question Claude asks the person (D86), with the same switch: one
28// question, one choice, two to four options, put to the panel first like a
29// permission and answered with the option the person picked there; any other,
30// or one unanswered in 55 seconds, gets the session's own dialog.
31//
32// And one question, asked by the person from the panel without disturbing the
33// session (D82): a message in this session's box that starts with LampBoard's
34// line, proven with that same key, is taken before the session sees it and
35// answered with a fork over the conversation — no turn, nothing added to it —
36// and only the answer goes to the panel. A message in that shape that is not
37// proven is taken all the same and answered by nobody.
38//
39// And a band above the prompt (D84): what waits for the person in the other
40// sessions — their names and one line each, as the panel's queue has them —
41// drawn on the screen, never put into the conversation; a digit opens that one
42// in the panel. Asked of the panel every few seconds, redrawn only on change.
43//
44// And the decision board (D105): what the person pinned in LampBoard for this
45// session's repository, handed to the model with the next prompt whenever it
46// has changed, as context the person does not see — asked with the permission
47// key and taken only signed with it, because these words enter the
48// conversation. Nothing pinned, or no panel, and the prompt goes as typed.
49//
50// And the radar (§4.4): before a file is written, the panel is asked whether
51// another live session wrote it lately; when one did, an edit the session would
52// have made on its own is put to the person first, with a sentence naming that
53// session. Asked and taken like the governor; anything else, the edit as it was.
54//
55// And away (A2): while the person is away, a shell command the engine would run
56// on its own and that the panel names destructive is put to them instead, so it
57// waits for their return rather than running unseen.
58//
59// And the governor (G3): a session the person lowered one model in the panel,
60// until the window resets, runs on that model — asked at the start of each turn,
61// with the permission key, and taken only signed with it. Nothing lowered, no
62// panel, or no signature: the session's own model, untouched.
63//
64// The colours of a row still come from LampBoard's hooks (decision D65); this
65// adds only figures. Wire format: version 1 of LampBoardCore/Mod/ModReport.swift.
66
67import { endLamps, registerLamps } from './lamps.js'
68import { endLook, registerLook } from './look.js'
69
70const VERSION = 1
71
72// A permission decides what a session may run, so the panel that answers one
73// is found from HOME and never from LAMPBOARD_HOME. A project's settings can set
74// LAMPBOARD_HOME — to a folder of its own, with a key and a port of its
75// choosing (a security review finding) — and cannot set HOME: measured on the
76// test Mac, with a project's settings setting both, the mod read the attacker's
77// LAMPBOARD_HOME and the real HOME.
78async function realHome($) {
79  try {
80    const home = await $.env.get('HOME')
81    return home && home.startsWith('/') && home !== '/' ? home : null
82  } catch (_) {
83    return null
84  }
85}
86
87// A project's own settings can set environment variables for its sessions, so
88// a cloned repository could point LAMPBOARD_HOME at itself and ship a port of
89// its choosing — and receive every session's reports. So the panel is found
90// from HOME alone, which a project's settings cannot set (measured on the test
91// Mac); a test runs its sessions with HOME set to its fake home. And the
92// address must answer as LampBoard before it is sent anything.
93let confirmed = null
94
95async function panel($) {
96  try {
97    const home = await realHome($)
98    if (!home) return null
99    const dir = `${home}/.lampboard`
100    const token = (await $.fs.read(`${dir}/token`)).trim()
101    const port = parseInt((await $.fs.read(`${dir}/port`)).trim(), 10)
102    if (!/^[0-9a-f]{16,128}$/.test(token) || !(port >= 1024 && port < 65536)) return null
103    const base = `http://127.0.0.1:${port}`
104    if (confirmed !== base) {
105      const health = await $.http.fetch(`${base}/health`)
106      if (!health.ok || health.text.trim() !== 'lampboard') return null
107      confirmed = base
108    }
109    return { base, url: `${base}/mod`, token }
110  } catch (_) {
111    return null
112  }
113}
114
115async function post($, kind, fields) {
116  try {
117    const target = await panel($)
118    if (!target) return
119    const body = JSON.stringify({ v: VERSION, kind, session: await $.session.id(), ...fields })
120    await $.http.fetch(target.url, {
121      method: 'POST',
122      headers: { 'Content-Type': 'application/json', 'X-LampBoard-Token': target.token },
123      body,
124    })
125  } catch (_) {
126    // The panel is closed, restarting or older than this mod: nothing to do.
127  }
128}
129
130// Whether the session runs on Claude Code's default configuration: then its
131// rate-limit windows are those of the account the panel's allowance strip
132// shows. Only whether the variable is set is sent, never its value.
133async function config($) {
134  try { return (await $.env.get('CLAUDE_CONFIG_DIR')) ? 'own' : 'default' } catch (_) { return undefined }
135}
136
137// The same allow-list the panel applies to a permission prompt: the first line
138// of the shell command or which file, never a tool's free-form input. One line,
139// so a heredoc's body never leaves; cut by characters, so no half of a pair.
140function detailOf(e) {
141  for (const key of ['command', 'file_path', 'notebook_path', 'path']) {
142    if (typeof e[key] === 'string' && e[key]) return Array.from(e[key].split('\n')[0]).slice(0, 120).join('')
143  }
144  return undefined
145}
146
147async function model($) {
148  try { return await $.session.model() } catch (_) { return undefined }
149}
150
151// HMAC-SHA256 by hand: the runtime offers `crypto.subtle.digest` and not the
152// keyed functions (measured, 2.1.289). The panel checks and signs with the same.
153const hex = (bytes) => Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('')
154async function sha256(bytes) {
155  return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
156}
157async function hmac(key, message) {
158  const encoder = new TextEncoder()
159  let k = encoder.encode(key)
160  if (k.length > 64) k = await sha256(k)
161  const pad = (byte) => Uint8Array.from({ length: 64 }, (_, i) => (k[i] || 0) ^ byte)
162  const m = encoder.encode(message)
163  const inner = new Uint8Array(64 + m.length)
164  inner.set(pad(0x36)); inner.set(m, 64)
165  const outer = new Uint8Array(96)
166  outer.set(pad(0x5c)); outer.set(await sha256(inner), 64)
167  return hex(await sha256(outer))
168}
169
170// How many lines an edit or a write touches (D87), counted from the call's own
171// input: numbers only leave the session, never the text.
172function linesOf(tool, input) {
173  // A final newline ends the last line; it does not start another.
174  const count = (text) => (typeof text === 'string' && text.length ? text.replace(/\n$/, '').split('\n').length : 0)
175  if (tool === 'Edit') return { removed: count(input.old_string), added: count(input.new_string) }
176  if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
177    return input.edits.reduce((sum, edit) => ({
178      removed: sum.removed + count(edit && edit.old_string), added: sum.added + count(edit && edit.new_string),
179    }), { removed: 0, added: 0 })
180  }
181  if (tool === 'Write') return { removed: 0, added: count(input.content) }
182  return undefined
183}
184
185// Whether a command goes on past the one line the card shows (D87): its
186// second line or its 121st character is not read there, so the card says so.
187function goesOn(input) {
188  const command = typeof input.command === 'string' ? input.command.replace(/\n+$/, '') : ''
189  return command.includes('\n') || Array.from(command).length > 120
190}
191
192// A question Claude asks (D86): put to the panel only in a shape a card can
193// show — one question, one choice, two to four options — proven with the
194// permission key like a permission, and answered only with a signed choice.
195async function askPanel($, e) {
196  try {
197    const questions = e.questions || (e.input && e.input.questions) || []
198    if (questions.length !== 1) return null
199    const q = questions[0]
200    const options = Array.isArray(q.options) ? q.options.map((o) => o && o.label) : []
201    if (q.multiSelect || (q.kind && q.kind !== 'choice') || options.length < 2 || options.length > 4) return null
202    if (!options.every((label) => typeof label === 'string' && label)) return null
203    const home = await realHome($)
204    if (!home) return null
205    const key = (await $.fs.read(`${home}/.lampboard/check-key`)).trim()
206    const port = parseInt((await $.fs.read(`${home}/.lampboard/port`)).trim(), 10)
207    if (!/^[0-9a-f]{16,128}$/.test(key) || !(port >= 1024 && port < 65536)) return null
208    const session = await $.session.id()
209    const nonce = crypto.randomUUID()
210    const reply = await $.http.fetch(`http://127.0.0.1:${port}/question`, {
211      method: 'POST',
212      headers: {
213        'Content-Type': 'application/json',
214        'X-LampBoard-Nonce': nonce,
215        'X-LampBoard-Proof': await hmac(key, `question:${nonce}:${session}:${e.tool_use_id}`),
216      },
217      body: JSON.stringify({ v: VERSION, session, id: e.tool_use_id, question: q.question, header: q.header, options }),
218    })
219    const [word, index, signature] = reply.ok ? reply.text.trim().split(' ') : []
220    if (word !== 'choose' || !/^[0-3]$/.test(index || '')) return null
221    if (!same(signature, await hmac(key, `choose:${nonce}:${index}`))) return null
222    const label = options[Number(index)]
223    if (label === undefined) return null
224    return { result: { questions, answers: { [q.question]: label } } }
225  } catch (_) {
226    return null
227  }
228}
229
230// The same comparison whatever the guess: a proof is not found a byte at a time.
231function same(a, b) {
232  if (typeof a !== 'string' || a.length !== b.length) return false
233  let diff = 0
234  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
235  return diff === 0
236}
237
238// A side question from the panel (D82), as PeerAsk in LampBoardCore writes it.
239const ASK = /^LampBoard asks without disturbing \[v1 ([A-Za-z0-9-]{16,64}) ([0-9a-f]{64})\]:\n([\s\S]{1,2000})$/
240
241async function answerQuietly($, nonce, proof, question) {
242  try {
243    const home = await realHome($)
244    if (!home) return
245    const key = (await $.fs.read(`${home}/.lampboard/check-key`)).trim()
246    if (!/^[0-9a-f]{16,128}$/.test(key)) return
247    const session = await $.session.id()
248    if (!same(proof, await hmac(key, `fork:${nonce}:${session}:${question}`))) return
249    const reply = await $.model.fork({ prompt: question })
250    // The first post that carries words of the conversation: the port is asked
251    // again whether it is LampBoard, not taken on an earlier answer.
252    confirmed = null
253    await post($, 'answer', reply.isAnswered ? { id: nonce, text: reply.text } : { id: nonce, reason: reply.reason })
254  } catch (_) {
255    // The panel waits a minute and says it heard nothing.
256  }
257}
258
259// The decision board (D105): the version each session was last handed, so a
260// board reaches a session once per change and not with every prompt. Recorded
261// only once the prompt has entered with it, and forgotten when the session
262// compacts or starts again, which can drop what it was handed.
263const boards = new Map()
264const BOARD_WAIT = 1500
265
266async function boardContext($) {
267  try {
268    const target = await panel($)
269    const home = await realHome($)
270    if (!target || !home) return null
271    const key = (await $.fs.read(`${home}/.lampboard/check-key`)).trim()
272    if (!/^[0-9a-f]{16,128}$/.test(key)) return null
273    const session = await $.session.id()
274    const nonce = crypto.randomUUID()
275    // A prompt never waits long on a dashboard: past this it goes as typed.
276    let timer
277    const late = new Promise((resolve) => { timer = setTimeout(() => resolve(null), BOARD_WAIT) })
278    const reply = await Promise.race([late, $.http.fetch(`${target.base}/mod/decisions`, {
279      method: 'POST',
280      headers: {
281        'Content-Type': 'application/json',
282        'X-LampBoard-Token': target.token,
283        'X-LampBoard-Nonce': nonce,
284        'X-LampBoard-Proof': await hmac(key, `board:${nonce}:${session}`),
285      },
286      body: JSON.stringify({ v: VERSION, session }),
287    })]).finally(() => clearTimeout(timer))
288    const cut = reply && reply.ok ? reply.text.indexOf('\n') : -1
289    if (cut < 0) return null
290    const [word, version, signature] = reply.text.slice(0, cut).split(' ')
291    const text = reply.text.slice(cut + 1)
292    if (word !== 'board' || !/^([0-9a-f]{16}|-)$/.test(version || '')) return null
293    if (!same(signature, await hmac(key, `pinned:${nonce}:${version}:${text}`))) return null
294    // Never told and nothing pinned, or told this very version: nothing new.
295    if (version === (boards.get(session) || '-')) return null
296    return { session, version, text }
297  } catch (_) {
298    return null
299  }
300}
301
302// The radar (§4.4): another live session's recent write of this file, in a
303// sentence for the person, or nothing.
304const WRITERS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
305const RADAR_WAIT = 600
306
307// A question the panel answers with a verdict and, for the one that acts, a
308// sentence — signed over both with the permission key (the radar, D113; the
309// hold, A2). Nothing, or anything unsigned, is no answer.
310async function signedVerdict($, route, tag, subject, fields, acting, wait) {
311  try {
312    const target = await panel($)
313    const home = await realHome($)
314    if (!target || !home) return null
315    const key = (await $.fs.read(`${home}/.lampboard/check-key`)).trim()
316    if (!/^[0-9a-f]{16,128}$/.test(key)) return null
317    const session = await $.session.id()
318    const nonce = crypto.randomUUID()
319    let timer
320    // Every call the engine would allow waits on this: shorter than the others.
321    const late = new Promise((resolve) => { timer = setTimeout(() => resolve(null), wait) })
322    const reply = await Promise.race([late, $.http.fetch(`${target.base}${route}`, {
323      method: 'POST',
324      headers: {
325        'Content-Type': 'application/json',
326        'X-LampBoard-Token': target.token,
327        'X-LampBoard-Nonce': nonce,
328        'X-LampBoard-Proof': await hmac(key, `${tag}:${nonce}:${session}:${subject}`),
329      },
330      body: JSON.stringify({ v: VERSION, session, ...fields }),
331    })]).finally(() => clearTimeout(timer))
332    const cut = reply && reply.ok ? reply.text.indexOf('\n') : -1
333    const head = reply && reply.ok ? (cut < 0 ? reply.text : reply.text.slice(0, cut)).trim() : ''
334    const sentence = cut < 0 ? '' : reply.text.slice(cut + 1)
335    const [verdict, signature] = head.split(' ')
336    if (verdict !== acting || !sentence) return null
337    if (!same(signature, await hmac(key, `${tag}:${nonce}:${verdict}:${sentence}`))) return null
338    return Array.from(sentence.replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ')).slice(0, 300).join('')
339  } catch (_) {
340    return null
341  }
342}
343
344// The radar (§4.4): another live session's recent write of this file.
345const radar = ($, file) => signedVerdict($, '/mod/radar', 'radar', file, { file }, 'written', RADAR_WAIT)
346
347// The hold (A2): away, a destructive command waits for the person. The whole
348// command goes, every line — cut at the panel's limit, and said so; only Bash.
349const HOLD_LONGEST = 4000
350const HOLD_WAIT = 1500
351const hold = ($, input) => {
352  const whole = Array.from(typeof input.command === 'string' ? input.command : '')
353  if (whole.length === 0) return null
354  const command = whole.slice(0, HOLD_LONGEST).join('')
355  const cut = whole.length > HOLD_LONGEST
356  return signedVerdict($, '/mod/hold', 'hold', `${cut ? 1 : 0}:${command}`, { command, cut }, 'hold', HOLD_WAIT)
357}
358
359// The governor (G3): the answer each session is waiting for at its turn's start,
360// asked before the turn goes on so that its first step already has it.
361const governed = new Map()
362
363async function governorModel($) {
364  try {
365    const target = await panel($)
366    const home = await realHome($)
367    if (!target || !home) return null
368    const key = (await $.fs.read(`${home}/.lampboard/check-key`)).trim()
369    if (!/^[0-9a-f]{16,128}$/.test(key)) return null
370    const session = await $.session.id()
371    const nonce = crypto.randomUUID()
372    let timer
373    const late = new Promise((resolve) => { timer = setTimeout(() => resolve(null), BOARD_WAIT) })
374    const reply = await Promise.race([late, $.http.fetch(`${target.base}/mod/governor`, {
375      method: 'POST',
376      headers: {
377        'Content-Type': 'application/json',
378        'X-LampBoard-Token': target.token,
379        'X-LampBoard-Nonce': nonce,
380        'X-LampBoard-Proof': await hmac(key, `governor:${nonce}:${session}`),
381      },
382      body: JSON.stringify({ v: VERSION, session }),
383    })]).finally(() => clearTimeout(timer))
384    const [word, chosen, signature] = reply && reply.ok ? reply.text.trim().split(' ') : []
385    if (word !== 'model' || !/^(claude-[a-z0-9.-]{1,60}|-)$/.test(chosen || '')) return null
386    if (!same(signature, await hmac(key, `governed:${nonce}:${chosen}`))) return null
387    return chosen === '-' ? null : chosen
388  } catch (_) {
389    return null
390  }
391}
392
393// The band (D84): what the panel says waits elsewhere, kept between draws,
394// per session — one process can hold several (the Claude app's chats) — with
395// one clock each, stopped when that session ends.
396const BAND_EVERY = 5000
397const bands = new Map()
398
399async function refreshBand($, session) {
400  const state = bands.get(session)
401  if (!state) return
402  let text = '{"items":[],"v":1}'
403  try {
404    const target = await panel($)
405    if (target) {
406      const reply = await $.http.fetch(`${target.base}/mod/band`, {
407        headers: { 'X-LampBoard-Token': target.token, 'X-LampBoard-Session': session },
408      })
409      if (reply.ok) text = reply.text
410    }
411  } catch (_) {
412    // The panel is closed or restarting: nothing waits that it can show.
413  }
414  if (text === state.text) return
415  try {
416    const read = JSON.parse(text)
417    if (read.v !== 1 || !Array.isArray(read.items)) return
418    state.text = text
419    state.items = read.items
420    $.ui.invalidate('ui.render')
421  } catch (_) {}
422}
423
424async function openInPanel($, session) {
425  try {
426    const target = await panel($)
427    if (!target) return
428    await $.http.fetch(`${target.base}/mod/band/open`, {
429      method: 'POST',
430      headers: { 'Content-Type': 'application/json', 'X-LampBoard-Token': target.token },
431      body: JSON.stringify({ session }),
432    })
433  } catch (_) {}
434}
435
436// `/lampmaster <question>` (D71): the person's question to LampMaster, through
437// the route the `lampmaster` MCP server uses, so the limits, the daily ceiling
438// and the off switch are the same ones. The answer is shown to the person and
439// is not handed to the model: it summarises other sessions' work, and what of
440// it this session should act on is the person's call. The model has the MCP
441// tools for its own questions.
442const USAGE = 'Ask LampMaster what your other sessions know: /lampmaster <question>'
443// Claude Code empties a box above 10,000 characters; LampBoard's answers stay
444// near 2,000.
445const MAX_ANSWER = 4000
446
447// The baton (5.4, D91): this session writes what another needs — a fork over
448// its own conversation, no turn — and LampBoard puts it in that session's
449// composer, unsent. The question is LampBoardCore's Handoff.question, word for word.
450const HANDOFF_USAGE = 'Write a handoff for another session: /handoff <session name>'
451const HANDOFF_QUESTION = 'Another Claude Code session is taking over from you, or depends on your work. Write the handoff it will read before it starts: what you understood, what you decided and why, what is left to do, and the files you touched. Plain text, at most thirty lines, no secret values.'
452
453async function handOver($, to) {
454  if (!(await panel($))) return 'LampBoard is not running on this Mac: there is nowhere to hand over to.'
455  try {
456    // Proven with the permission key, never the token alone (D80): the token
457    // travels with every hook, and would let anyone write in a composer.
458    const home = await realHome($)
459    const key = home ? (await $.fs.read(`${home}/.lampboard/check-key`)).trim() : ''
460    if (!/^[0-9a-f]{16,128}$/.test(key)) return 'LampBoard has no key to prove the handoff with: update the panel.'
461    const reply = await $.model.fork({ prompt: HANDOFF_QUESTION })
462    if (!reply.isAnswered || !reply.text || !reply.text.trim()) return `No handoff written (${reply.reason || 'no answer'}).`
463    // Words of the conversation: the port is asked again whether it is LampBoard.
464    confirmed = null
465    const target = await panel($)
466    if (!target) return 'LampBoard went away while the handoff was written.'
467    const text = Array.from(reply.text.trim()).slice(0, MAX_ANSWER).join('')
468    const session = await $.session.id()
469    const nonce = crypto.randomUUID()
470    const answer = await $.http.fetch(`${target.base}/handoff`, {
471      method: 'POST',
472      headers: {
473        'Content-Type': 'application/json',
474        'X-LampBoard-Token': target.token,
475        'X-LampBoard-Nonce': nonce,
476        'X-LampBoard-Proof': await hmac(key, `handoff:${nonce}:${session}:${to}:${text}`),
477      },
478      body: JSON.stringify({ v: VERSION, session, to, text }),
479    })
480    if (!answer.ok) return `LampBoard did not take the handoff (HTTP ${answer.status}).`
481    // Shown to the person: no control, no direction-changing character.
482    return Array.from(answer.text.trim().replace(/[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2066-\u2069]/g, ''))
483      .slice(0, 400).join('')
484  } catch (_) {
485    return 'The handoff could not be written.'
486  }
487}
488
489async function ask($, question) {
490  const target = await panel($)
491  if (!target) return 'LampBoard is not running on this Mac, so LampMaster cannot answer.'
492  try {
493    const reply = await $.http.fetch(`${target.base}/lampmaster/tool`, {
494      method: 'POST',
495      headers: { 'Content-Type': 'application/json', 'X-LampBoard-Token': target.token },
496      body: JSON.stringify({
497        tool: 'ask_lampmaster',
498        arguments: { question },
499        session: await $.session.id(),
500        cwd: await $.session.cwd(),
501      }),
502    })
503    if (!reply.ok) return `LampBoard did not take the question (HTTP ${reply.status}).`
504    const answer = JSON.parse(reply.text)
505    if (typeof answer.text !== 'string' || !answer.text.trim()) return 'LampMaster gave no answer.'
506    // LampBoard sends it clean already; the mod does not take a terminal's
507    // safety on trust from whatever answered on that port. Line breaks stay.
508    return Array.from(answer.text.trim().replace(/[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/g, ''))
509      .slice(0, MAX_ANSWER).join('')
510  } catch (_) {
511    return 'LampMaster could not be reached.'
512  }
513}
514
515export function register(on) {
516  on('session.start', async ($, e, next) => {
517    const result = await next(e)
518    try { boards.delete(await $.session.id()) } catch (_) {}
519    // One registration each, each on its own: a name another plugin already
520    // holds is refused (claude-mem has a `handoff` skill), and in one `try`
521    // that refusal took every command after it away (measured, 8 October 2026).
522    // Immediate: a question about the other sessions does not depend on this
523    // one's turn, and is most useful while that turn is still running.
524    try {
525      await $.command.register({ name: 'lampmaster', description: 'Ask LampMaster what your other sessions know', argumentHint: '<question>', immediate: true })
526    } catch (_) {}
527    try {
528      await $.command.register({ name: 'handoff', description: 'Write a handoff for another session; it waits in its LampBoard composer', argumentHint: '<session>', immediate: true })
529    } catch (_) {}
530    try {
531      await $.command.register({ name: 'lamps', description: 'Every LampBoard lamp, beside this conversation', immediate: true })
532    } catch (_) {}
533    await post($, 'start', { surface: e.surface, interactive: e.isInteractive, model: await model($), features: ['ask'] })
534    // A band only where a person reads it, and one clock per session.
535    const session = await $.session.id()
536    if (e.isInteractive && !bands.has(session)) {
537      bands.set(session, { text: '', items: [], clock: setInterval(() => { void refreshBand($, session) }, BAND_EVERY) })
538      void refreshBand($, session)
539    }
540    return result
541  })
542
543  on('session.measure', async ($, e, next) => {
544    const result = await next(e)
545    await post($, 'measure', {
546      model: await model($),
547      config: await config($),
548      context: { tokens: e.context.tokens, window: e.context.window },
549      rateLimits: e.rateLimits.map((r) => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
550      cost: e.cost ? { usd: e.cost.usd } : undefined,
551    })
552    return result
553  })
554
555  // Which tool runs, and since when, so the panel can tell a long build from a
556  // command left waiting (5.7). The posts are not awaited: a tool call must
557  // never wait on the panel, and they cannot throw.
558  on('tool.call', async ($, e, next) => {
559    // A question answered from the panel never reaches the dialog.
560    if (e.tool === 'AskUserQuestion' && e.tool_use_id) {
561      const answered = await askPanel($, e)
562      if (answered) return answered
563    }
564    const id = e.tool_use_id
565    if (id) void post($, 'tool', { id, tool: e.tool, phase: 'start', detail: detailOf(e) })
566    try {
567      return await next(e)
568    } finally {
569      if (id) void post($, 'tool', { id, tool: e.tool, phase: 'end' })
570    }
571  })
572
573  on('command.run', { command: 'handoff' }, async ($, e) => {
574    const to = (e.args || '').trim().replace(/^@/, '')
575    if (!to || Array.from(to).length > 80) return { text: HANDOFF_USAGE }
576    return { text: await handOver($, to) }
577  })
578
579  on('command.run', { command: 'lampmaster' }, async ($, e) => {
580    // LampBoard cuts the question to its own limit; this only trims it.
581    const question = (e.args || '').trim()
582    if (!question) return { text: USAGE }
583    // Claude Code prints the plugin's name before the text: no label of our own.
584    return { text: await ask($, question) }
585  })
586
587  // Only what the engine would ask the person about, and only a real call: the
588  // engine's allow and deny are never overturned, and a query is no question.
589  // LampBoard answers `ask` at once while its switch is off.
590  on('tool.check', async ($, e, next) => {
591    const verdict = await next(e)
592    // Away (A2): a destructive command the engine would run on its own waits.
593    if (verdict && verdict.decision === 'allow' && e.tool === 'Bash') {
594      const said = await hold($, e.input || {})
595      if (said) {
596        try { void $.ui.toast(`LampBoard · ${said}`, { timeoutMs: 20000 }) } catch (_) {}
597        return { decision: 'ask', reason: said }
598      }
599    }
600    // The radar: an edit the session would make on its own, of a file another
601    // live session just wrote, is put to the person first.
602    if (verdict && verdict.decision === 'allow' && WRITERS.has(e.tool)) {
603      const input = e.input || {}
604      const file = typeof input.file_path === 'string' ? input.file_path : input.notebook_path
605      if (typeof file === 'string' && file.startsWith('/')) {
606        const said = await radar($, file)
607        if (said) {
608          // The edit dialog does not show a hook's reason: the session says it.
609          try { void $.ui.toast(`LampBoard · ${said}`, { timeoutMs: 20000 }) } catch (_) {}
610          return { decision: 'ask', reason: said }
611        }
612      }
613    }
614    if (!verdict || verdict.decision !== 'ask' || !e.tool_use_id) return verdict
615    try {
616      const home = await realHome($)
617      if (!home) return verdict
618      // Not the token: every hook and report carries that to whatever answers
619      // on the port, which while the panel is away could be anyone's listener.
620      const key = (await $.fs.read(`${home}/.lampboard/check-key`)).trim()
621      const port = parseInt((await $.fs.read(`${home}/.lampboard/port`)).trim(), 10)
622      if (!/^[0-9a-f]{16,128}$/.test(key) || !(port >= 1024 && port < 65536)) return verdict
623      const session = await $.session.id()
624      const nonce = crypto.randomUUID()
625      const reply = await $.http.fetch(`http://127.0.0.1:${port}/check`, {
626        method: 'POST',
627        headers: {
628          'Content-Type': 'application/json',
629          'X-LampBoard-Nonce': nonce,
630          'X-LampBoard-Proof': await hmac(key, `ask:${nonce}:${session}:${e.tool_use_id}`),
631        },
632        body: JSON.stringify({
633          v: VERSION, session, id: e.tool_use_id, tool: e.tool, detail: detailOf(e.input || {}),
634          lines: linesOf(e.tool, e.input || {}), more: goesOn(e.input || {}),
635        }),
636      })
637      // Only an answer signed with the key counts: a listener without it, on
638      // a port the panel left, cannot say allow.
639      const [decision, signature] = reply.ok ? reply.text.trim().split(' ') : []
640      if (!signature || signature !== (await hmac(key, `answer:${nonce}:${decision}`))) return verdict
641      if (decision === 'allow') return { decision: 'allow', reason: 'Allowed from LampBoard.' }
642      if (decision === 'deny') return { decision: 'deny', reason: 'Denied from LampBoard.' }
643    } catch (_) {
644      // The panel is closed or restarting: the dialog, as without it.
645    }
646    return verdict
647  })
648
649  // Taken whether or not it is proven: a message that starts like this is never the
650  // session's to read. The answer is not awaited: the delivery is not held.
651  on('session.receive', async ($, e, next) => {
652    const text = e.text || ''
653    if (!text.startsWith('LampBoard asks without disturbing [')) return next(e)
654    // Its line, but not its shape (cut, too long): taken all the same.
655    const asked = ASK.exec(text)
656    if (asked) void answerQuietly($, asked[1], asked[2], asked[3])
657    return { consumed: 'lampboard-ask' }
658  })
659
660  // The band: nothing while nothing waits elsewhere, so the engine draws its own.
661  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
662    const state = bands.get(await $.session.id())
663    const items = state ? state.items.slice(0, 3) : []
664    if (items.length === 0 || (e.props && e.props.hasSurvey)) return next(e)
665    const { Box, Text, Button } = $.ui.resolve(e)
666    // One line whatever the width: each label gets its share of the columns.
667    const columns = (e.props && e.props.bodyColumns) || 80
668    const share = Math.max(12, Math.floor((columns - 14) / items.length) - 6)
669    const fit = (label) => (Array.from(label).length <= share ? label : Array.from(label).slice(0, share - 1).join('') + '…')
670    const children = [Text({ color: 'yellow', children: ['⚑ LampBoard · '] })]
671    items.forEach((item, i) => {
672      children.push(Button({
673        key: `band-${i + 1}`, label: fit(`${item.title}: ${item.line}`), hotkey: String(i + 1), plain: true,
674        onPress: async () => { await openInPanel($, item.session) },
675      }))
676      if (i < items.length - 1) children.push(Text({ dimColor: true, children: [' · '] }))
677    })
678    return Box({ flexDirection: 'row', children })
679  })
680
681  // Context on the way down, before the prompt enters: once it has, a block
682  // attached is not read (the API's word).
683  on('prompt.submit', async ($, e, next) => {
684    const board = await boardContext($)
685    const block = board && board.text
686    const result = await next(block ? { ...e, context: [...(e.context || []), block] } : e)
687    if (board && !result.drop) boards.set(board.session, board.version)
688    return result
689  })
690
691  // The governor (G3): asked once a turn, applied to every step of it.
692  on('turn.start', async ($, e, next) => {
693    try { governed.set(await $.session.id(), governorModel($)) } catch (_) {}
694    return next(e)
695  })
696
697  // A streaming event: its hook is an async generator, or it is dropped unseen.
698  on('turn.step', async function* ($, e, next) {
699    let lowered
700    try { lowered = await governed.get(await $.session.id()) } catch (_) {}
701    return yield* next(lowered && !e.agentId ? { ...e, model: lowered } : e)
702  })
703
704  // A compacted or restarted conversation may have lost what it was handed.
705  on('session.compact', async ($, e, next) => {
706    const result = await next(e)
707    if (!e.agentId) { try { boards.delete(await $.session.id()) } catch (_) {} }
708    return result
709  })
710
711  // `session.end` has 1.5 s in all: one short post, and no model lookup.
712  on('session.end', async ($, e, next) => {
713    const result = await next(e)
714    const ending = bands.get(e.sessionId)
715    if (ending) { clearInterval(ending.clock); bands.delete(e.sessionId) }
716    endLamps(e.sessionId)
717    endLook(e.sessionId)
718    boards.delete(e.sessionId)
719    governed.delete(e.sessionId)
720    await post($, 'end', { session: e.sessionId, reason: e.reason })
721    return result
722  })
723
724  // Last, and on its own: a Claude Code without panes must not lose the rest.
725  try { registerLamps(on) } catch (_) {}
726  // The look (D134), off unless LampBoard says on for the session.
727  try { registerLook(on) } catch (_) {}
728}
729
hooks/lamps.js 178 lines
1// The lamps (D131): every session the LampBoard panel on this Mac shows, the
2// ones that want something first, in a pane beside the conversation. Opened by
3// /lamps and never by itself: a pane that opens unasked takes the width of
4// somebody's work. It reads the panel's own `/sessions`, with the panel's token,
5// and a row's button raises that session the way a click on its lamp does.
6// `/lamps` itself is registered in register.js's `session.start`: the engine
7// takes one hook per event, and follows `$` into no imported function.
8
9const PANE = 'lampboard-lamps'
10const EVERY = 3000
11const WAIT = 2000
12const MOST = 30
13
14// The glossary of 1.1 (D128) and the panel's own colours.
15const WORDS = { awaiting: 'needs you', failed: 'stopped', ready: 'done', working: 'working', waiting: 'paused', idle: 'resting' }
16const COLOR = { awaiting: '#ff7319', failed: '#d93d3d', ready: '#33d96b', working: '#fabf29', waiting: '#6ba8fa', idle: '#9e9e9e' }
17const ORDER = { awaiting: 0, failed: 1, ready: 2, working: 3, waiting: 4, idle: 5 }
18// The rule the panel holds a session id to (`ModReport.isSessionId`).
19const SESSION_ID = /^[A-Za-z0-9-]{8,64}$/
20
21// One state per session: one hooks worker serves every session of the process,
22// and a pane, its rows and its clock belong to the session that opened it.
23const panes = new Map()
24
25// The panel, found the way register.js finds it, and copied rather than shared:
26// Claude Code's check follows `$` only into functions declared in the same file,
27// and refuses a module that hands `$` to an imported one. HOME alone, never
28// LAMPBOARD_HOME, which a cloned project's settings could point anywhere; and the
29// address must answer as LampBoard before it is asked anything.
30let confirmedLampBoard = null
31
32// No call to the panel waits longer than this: a stuck panel must not hold a
33// command or pile up refreshes.
34function bounded(promise) {
35  let timer
36  const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('late')), WAIT) })
37  return Promise.race([promise, late]).finally(() => clearTimeout(timer))
38}
39
40async function lampBoard($) {
41  try {
42    const home = await $.env.get('HOME')
43    if (!home || !home.startsWith('/') || home === '/') return null
44    const token = (await $.fs.read(`${home}/.lampboard/token`)).trim()
45    const port = parseInt((await $.fs.read(`${home}/.lampboard/port`)).trim(), 10)
46    if (!/^[0-9a-f]{16,128}$/.test(token) || !(port >= 1024 && port < 65536)) return null
47    const base = `http://127.0.0.1:${port}`
48    if (confirmedLampBoard !== base) {
49      const health = await bounded($.http.fetch(`${base}/health`))
50      if (!health.ok || health.text.trim() !== 'lampboard') return null
51      confirmedLampBoard = base
52    }
53    return { base, token }
54  } catch (_) {
55    return null
56  }
57}
58
59async function raise($, session) {
60  try {
61    const target = await lampBoard($)
62    if (!target) { $.ui.toast('LampBoard is not answering.'); return }
63    const reply = await bounded($.http.fetch(`${target.base}/mod/band/open`, {
64      method: 'POST',
65      headers: { 'Content-Type': 'application/json', 'X-LampBoard-Token': target.token },
66      body: JSON.stringify({ session }),
67    }))
68    if (!reply.ok) $.ui.toast('LampBoard could not bring that session forward.')
69  } catch (_) {
70    $.ui.toast('LampBoard is not answering.')
71  }
72}
73
74// What a session calls itself reaches a terminal: one line, no control, format,
75// bidi or invisible characters (a title can come from a cloned repository), cut.
76function clean(text) {
77  const flat = String(text).replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u2028-\u202E\u2060-\u206F\uFEFF]|[\u{E0000}-\u{E007F}]/gu, ' ')
78  return Array.from(flat).slice(0, 80).join('').trim()
79}
80
81async function refresh($, session) {
82  const pane = panes.get(session)
83  if (!pane || pane.busy) return
84  pane.busy = true
85  let text = ''
86  try {
87    const target = await lampBoard($)
88    if (target) {
89      const reply = await bounded($.http.fetch(`${target.base}/sessions`, { headers: { 'X-LampBoard-Token': target.token } }))
90      if (reply.ok) text = reply.text
91    }
92  } catch (_) {
93    text = ''
94  } finally {
95    pane.busy = false
96  }
97  if (text === pane.text) return
98  pane.text = text
99  pane.rows = []
100  try {
101    const read = text ? JSON.parse(text) : { sessions: [] }
102    const seen = new Set()
103    pane.rows = (Array.isArray(read.sessions) ? read.sessions : [])
104      .filter((s) => s && typeof s.id === 'string' && SESSION_ID.test(s.id) && WORDS[s.status] && !seen.has(s.id) && seen.add(s.id))
105      .map((s) => ({ id: s.id, status: s.status, name: clean(s.title || s.workspace || s.id) || s.id }))
106      .sort((a, b) => ORDER[a.status] - ORDER[b.status] || a.name.localeCompare(b.name))
107      .slice(0, MOST)
108  } catch (_) {
109    pane.rows = []
110  }
111  $.ui.invalidate('ui.render')
112}
113
114// Called from register.js's `session.end` (which has the id and takes no `$`
115// here): a pane's clock does not outlive its session.
116export function endLamps(session) {
117  const pane = panes.get(session)
118  if (pane && pane.clock) clearInterval(pane.clock)
119  panes.delete(session)
120}
121
122export function registerLamps(on) {
123  on('command.run', { command: 'lamps' }, async ($) => {
124    const session = await $.session.id()
125    try {
126      await $.ui.open({ id: PANE, title: 'LampBoard' })
127    } catch (_) {
128      return { text: 'This layout has no room for a pane: run Claude Code full screen, as a background session always is.' }
129    }
130    const pane = panes.get(session) || { text: null, rows: [], clock: null, busy: false }
131    panes.set(session, pane)
132    if (!pane.clock) pane.clock = setInterval(() => { void refresh($, session) }, EVERY)
133    void refresh($, session)
134    return { text: 'The lamps are beside the conversation.' }
135  })
136
137  // Nobody looks at a closed pane: the panel is not asked again until it opens.
138  on('ui.close', async ($, e, next) => {
139    if (e.id === PANE) {
140      try {
141        const pane = panes.get(await $.session.id())
142        if (pane && pane.clock) { clearInterval(pane.clock); pane.clock = null }
143      } catch (_) {}
144    }
145    return next(e)
146  })
147
148  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
149    const { Box, Text, Button } = $.ui.resolve(e)
150    const pane = panes.get(await $.session.id())
151    if (!pane || pane.text === null) return Text({ dimColor: true, children: ['Asking LampBoard…'] })
152    if (pane.rows.length === 0) return Text({ dimColor: true, children: ['No lamps: LampBoard is closed, or quiet.'] })
153    const columns = (e.props && e.props.bodyColumns) || 40
154    const room = Math.max(1, ((e.viewport && e.viewport.rows) || 24) - 3)
155    // The dot, a hotkey's `1: `, a space and the longest word ("needs you").
156    const width = Math.max(8, columns - 17)
157    const name = (row) => (Array.from(row.name).length <= width ? row.name : Array.from(row.name).slice(0, width - 1).join('') + '…')
158    const shown = pane.rows.slice(0, room)
159    const children = shown.map((row, i) => Box({
160      key: `lamp-${row.id}`,
161      flexDirection: 'row',
162      children: [
163        Text({ color: COLOR[row.status], children: [row.status === 'idle' ? '○ ' : '● '] }),
164        Button({
165          key: `open-${row.id}`, label: name(row), plain: true,
166          ...(i < 9 ? { hotkey: String(i + 1) } : {}),
167          onPress: async () => { await raise($, row.id) },
168        }),
169        Text({ dimColor: true, children: [` ${WORDS[row.status]}`] }),
170      ],
171    }))
172    if (pane.rows.length > shown.length) {
173      children.push(Text({ key: 'more', dimColor: true, children: [`+${pane.rows.length - shown.length} more`] }))
174    }
175    return Box({ flexDirection: 'column', children })
176  })
177}
178
hooks/look.js 545 lines
1// The look (D134): inside LampBoard's live view, the transcript drawn as
2// close to Claude Code's VS Code panel as a terminal allows. The person's
3// prompts sit in rounded boxes; each tool call is one compact row (a status
4// dot, the tool, its file or command, + and - counts for an edit); an edit's
5// result is a diff in a rounded frame; a todo list is a checklist; the spinner
6// says what the turn is doing in one plain word; the hint under the prompt
7// ends with the model and how full the context is.
8//
9// Off unless LampBoard says on, for this session (Settings > Clicks & keys >
10// Claude Code's look: in LampBoard's windows, everywhere, or off): the panel on
11// this Mac is asked `GET /mod/look` (its token, this session's id) and must
12// answer `{"v":1,"on":true}`. The answer is kept five seconds per session. Any
13// other answer, no panel, a late one, a surface other than the terminal: the
14// engine draws its own, untouched. It reads only what the engine is about to
15// draw, sends nothing but the session's id, and writes nothing.
16//
17// Claude Code's check follows `$` only into functions declared in the same file,
18// so the panel lookup is a copy of lamps.js's (HOME only, never LAMPBOARD_HOME;
19// the address must answer as LampBoard first). Every hook here has a component
20// matcher: the module graph may hold one hook per event without a matcher.
21
22const TTL = 5000
23const MISSES = 3
24const WAIT = 1500
25const SESSION_ID = /^[A-Za-z0-9-]{8,64}$/
26
27// Glyphs, written as escapes: the source holds ASCII only.
28const DOT = '\u25CF'
29const ELBOW = '\u2514 '
30const MINUS = '\u2212'
31const ELLIPSIS = '\u2026'
32const BOX_EMPTY = '\u2610'
33const BOX_DONE = '\u2611'
34const ARROW = '\u25B8'
35const CROSS = '\u2715'
36const MIDDOT = ' \u00B7 '
37
38// How many diff lines a result shows before saying how many it left out.
39const DIFF_LINES = 24
40const WRITE_LINES = 12
41
42// Tools whose row the look draws; any other keeps the engine's row.
43const DRAWN = new Set(['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash', 'Grep', 'Glob', 'LS',
44  'WebFetch', 'WebSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'Skill', 'ToolSearch'])
45
46// The task tools draw their own checklist line; their result row says nothing more.
47const TASKS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList'])
48
49// What the spinner says, by what the turn is doing.
50// Whose prompts are boxed: the person's, typed here or through an attach.
51const TYPED = new Set(['composer', 'bridge', 'sdk', 'unclassified'])
52
53const DOING = { thinking: 'Thinking', requesting: 'Waiting', responding: 'Writing', 'tool-input': 'Preparing', 'tool-use': 'Working' }
54
55// One state per session: the panel's last answer, when it came, the folder
56// paths are shown against, and the tool calls drawn inside an unfolded group.
57const looks = new Map()
58
59let confirmedLook = null
60
61function bounded(promise) {
62  let timer
63  const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('late')), WAIT) })
64  return Promise.race([promise, late]).finally(() => clearTimeout(timer))
65}
66
67async function lookPanel($) {
68  try {
69    const home = await $.env.get('HOME')
70    if (!home || !home.startsWith('/') || home === '/') return null
71    const token = (await $.fs.read(`${home}/.lampboard/token`)).trim()
72    const port = parseInt((await $.fs.read(`${home}/.lampboard/port`)).trim(), 10)
73    if (!/^[0-9a-f]{16,128}$/.test(token) || !(port >= 1024 && port < 65536)) return null
74    const base = `http://127.0.0.1:${port}`
75    if (confirmedLook !== base) {
76      const health = await bounded($.http.fetch(`${base}/health`))
77      if (!health.ok || health.text.trim() !== 'lampboard') return null
78      confirmedLook = base
79    }
80    return { base, token }
81  } catch (_) {
82    return null
83  }
84}
85
86async function askLook($, session) {
87  try {
88    const target = await lookPanel($)
89    if (!target) return false
90    const reply = await bounded($.http.fetch(`${target.base}/mod/look`, {
91      headers: { 'X-LampBoard-Token': target.token, 'X-LampBoard-Session': session },
92    }))
93    if (!reply.ok) { confirmedLook = null; return null }
94    const read = JSON.parse(reply.text)
95    return Boolean(read && read.v === 1 && read.on === true)
96  } catch (_) {
97    confirmedLook = null
98    return null
99  }
100}
101
102// The session's state, the panel asked at most once per TTL. The first ask is
103// awaited (bounded); a stale answer is used while a fresh one is fetched, and a
104// change redraws every hooked site.
105async function lookState($, e) {
106  if (!e || e.surface !== 'terminal') return null
107  try {
108    const session = await $.session.id()
109    if (!SESSION_ID.test(session)) return null
110    let state = looks.get(session)
111    if (!state) {
112      state = { on: false, at: 0, misses: 0, pending: null, cwd: '', grouped: new Set(), tasks: new Map() }
113      looks.set(session, state)
114    }
115    if (Date.now() - state.at >= TTL && !state.pending) {
116      const first = state.at === 0
117      state.pending = (async () => {
118        const said = await askLook($, session)
119        // No answer (a panel busy for a moment, or gone) keeps the last one,
120        // three times at most: a stall must not flicker the look off and on.
121        state.misses = said === null ? state.misses + 1 : 0
122        const on = said === null ? (state.misses < MISSES && state.on) : said
123        if (on && !state.cwd) { try { state.cwd = await $.session.cwd() } catch (_) {} }
124        const changed = on !== state.on
125        state.on = on
126        state.at = Date.now()
127        state.pending = null
128        // The rows drawn before the first answer are drawn again with it.
129        if (changed || (first && on)) { try { $.ui.invalidate('ui.render') } catch (_) {} }
130      })()
131    }
132    // Until the first answer every row waits for it (bounded), not just the
133    // one that asked.
134    if (state.at === 0 && state.pending) await state.pending
135    return state.on ? state : null
136  } catch (_) {
137    return null
138  }
139}
140
141// Called from register.js's `session.end`, which has the id and no `$` here.
142export function endLook(session) {
143  looks.delete(session)
144}
145
146// Text that reaches the terminal: no control, bidi or invisible characters;
147// tabs and line breaks kept only where asked.
148function clean(text, keepLines) {
149  const bad = keepLines
150    ? /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u200B-\u200F\u2028-\u202E\u2060-\u206F\uFEFF\u061C\u180E\uFFF9-\uFFFB]|[\u{E0000}-\u{E007F}]/gu
151    : /[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u2028-\u202E\u2060-\u206F\uFEFF\u061C\u180E\uFFF9-\uFFFB]|[\u{E0000}-\u{E007F}]/gu
152  return String(text == null ? '' : text).replace(bad, keepLines ? '' : ' ')
153}
154
155function cut(text, room) {
156  const chars = Array.from(text)
157  if (room < 2) return ''
158  return chars.length <= room ? text : chars.slice(0, room - 1).join('') + ELLIPSIS
159}
160
161// A path cut from its head, so the file's name survives.
162function cutPath(text, room) {
163  const chars = Array.from(text)
164  if (room < 2) return ''
165  return chars.length <= room ? text : ELLIPSIS + chars.slice(chars.length - room + 1).join('')
166}
167
168function relative(path, cwd) {
169  if (typeof path !== 'string') return ''
170  if (cwd && path.startsWith(cwd + '/')) return path.slice(cwd.length + 1)
171  return path
172}
173
174const count = (text) => (typeof text === 'string' && text.length ? text.replace(/\n$/, '').split('\n').length : 0)
175
176// The + and - of an edit: from the stored patch once there, else from the input.
177function changesOf(tool, input, output) {
178  const patch = output && Array.isArray(output.structuredPatch) ? output.structuredPatch : null
179  if (patch && patch.length) {
180    let added = 0
181    let removed = 0
182    for (const hunk of patch) {
183      for (const line of hunk.lines || []) {
184        if (line.startsWith('+')) added++
185        else if (line.startsWith('-')) removed++
186      }
187    }
188    return { added, removed }
189  }
190  if (tool === 'Edit') return { removed: count(input.old_string), added: count(input.new_string) }
191  if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
192    return input.edits.reduce((sum, edit) => ({
193      removed: sum.removed + count(edit && edit.old_string), added: sum.added + count(edit && edit.new_string),
194    }), { removed: 0, added: 0 })
195  }
196  if (tool === 'Write') return { removed: 0, added: count(input.content) }
197  return null
198}
199
200// What a row names: the file, the command, the pattern, the address.
201function targetOf(tool, input, cwd) {
202  const inp = input || {}
203  if (typeof inp.file_path === 'string') {
204    let text = relative(inp.file_path, cwd)
205    if (tool === 'Read' && (inp.offset || inp.limit)) {
206      const from = Number(inp.offset) || 1
207      text += inp.limit ? `:${from}-${from + Number(inp.limit) - 1}` : `:${from}`
208    }
209    return { text, isPath: true }
210  }
211  if (typeof inp.notebook_path === 'string') return { text: relative(inp.notebook_path, cwd), isPath: true }
212  if (typeof inp.command === 'string') return { text: inp.command.split('\n')[0], isPath: false }
213  if (typeof inp.pattern === 'string') {
214    const where = typeof inp.path === 'string' ? `  in ${relative(inp.path, cwd)}` : ''
215    return { text: inp.pattern + where, isPath: false }
216  }
217  if (typeof inp.path === 'string') return { text: relative(inp.path, cwd), isPath: true }
218  for (const key of ['url', 'query', 'skill', 'description']) {
219    if (typeof inp[key] === 'string') return { text: inp[key], isPath: false }
220  }
221  return { text: '', isPath: false }
222}
223
224// One dim line under a row: what the call came to.
225function summaryOf(tool, output) {
226  if (output == null) return ''
227  if (typeof output === 'string') return output.split('\n')[0]
228  if (tool === 'Read' && output.file) {
229    const lines = output.file.numLines
230    return typeof lines === 'number' ? `${lines} line${lines === 1 ? '' : 's'}` : ''
231  }
232  if (tool === 'Bash') {
233    const out = String(output.stdout || output.stderr || '').replace(/\n+$/, '')
234    if (!out) return 'no output'
235    const lines = out.split('\n')
236    return lines.length === 1 ? lines[0] : `${lines[0]}  (+${lines.length - 1} lines)`
237  }
238  if (Array.isArray(output.filenames)) return `${output.filenames.length} file${output.filenames.length === 1 ? '' : 's'}`
239  if (typeof output.numFiles === 'number') return `${output.numFiles} file${output.numFiles === 1 ? '' : 's'}`
240  if (Array.isArray(output.matches)) return `${output.matches.length} match${output.matches.length === 1 ? '' : 'es'}`
241  return ''
242}
243
244function nameOf(tool) {
245  const mcp = /^mcp__([^_]+(?:_[^_]+)*)__(.+)$/.exec(tool)
246  return mcp ? `${mcp[1]}${MIDDOT}${mcp[2]}` : tool
247}
248
249function dotColor(p) {
250  if (p.isInterrupted) return 'warning'
251  if (p.isErrored) return 'error'
252  if (p.isRunning) return 'subtle'
253  return 'success'
254}
255
256// The row: a dot, the tool in bold, its target, an edit's counts.
257function headerRow(t, p, cwd, columns) {
258  const { Box, Text } = t
259  const input = p.input || {}
260  const name = clean(nameOf(String(p.tool)))
261  const changes = changesOf(p.tool, input, p.output)
262  const counts = changes ? `  +${changes.added} ${MINUS}${changes.removed}` : ''
263  const target = targetOf(p.tool, input, cwd)
264  const room = Math.max(8, columns - Array.from(name).length - Array.from(counts).length - 6)
265  const shown = target.isPath ? cutPath(clean(target.text), room) : cut(clean(target.text), room)
266  const children = [
267    Text({ color: dotColor(p), children: [`${DOT} `] }),
268    Text({ bold: true, children: [name] }),
269  ]
270  if (shown) children.push(Text({ color: target.isPath ? 'suggestion' : 'text', children: [`  ${shown}`] }))
271  if (changes) {
272    children.push(Text({ color: 'success', children: [`  +${changes.added}`] }))
273    children.push(Text({ color: 'error', children: [` ${MINUS}${changes.removed}`] }))
274  }
275  if (p.isInterrupted) children.push(Text({ dimColor: true, children: ['  interrupted'] }))
276  return Box({ flexDirection: 'row', children })
277}
278
279function summaryRow(t, text, columns) {
280  const { Text } = t
281  return Text({ dimColor: true, children: [`  ${ELBOW}${cut(clean(text), Math.max(8, columns - 6))}`] })
282}
283
284// TodoWrite as a checklist: done, doing, to do.
285function todoCard(t, p, columns) {
286  const { Box, Text } = t
287  const todos = Array.isArray(p.input && p.input.todos) ? p.input.todos : []
288  const done = todos.filter((x) => x && x.status === 'completed').length
289  const rows = [Box({
290    flexDirection: 'row',
291    children: [
292      Text({ color: dotColor(p), children: [`${DOT} `] }),
293      Text({ bold: true, children: ['Todos'] }),
294      Text({ dimColor: true, children: [`  ${done}/${todos.length} done`] }),
295    ],
296  })]
297  const room = Math.max(8, columns - 8)
298  todos.slice(0, 20).forEach((todo, i) => {
299    if (!todo) return
300    if (todo.status === 'completed') {
301      rows.push(Text({ key: `todo-${i}`, dimColor: true, strikethrough: true, children: [`  ${BOX_DONE} ${cut(clean(todo.content), room)}`] }))
302    } else if (todo.status === 'in_progress') {
303      rows.push(Text({ key: `todo-${i}`, color: 'claude', bold: true, children: [`  ${ARROW} ${cut(clean(todo.activeForm || todo.content), room)}`] }))
304    } else {
305      rows.push(Text({ key: `todo-${i}`, children: [`  ${BOX_EMPTY} ${cut(clean(todo.content), room)}`] }))
306    }
307  })
308  if (todos.length > 20) rows.push(Text({ dimColor: true, children: [`  +${todos.length - 20} more`] }))
309  return Box({ flexDirection: 'column', children: rows })
310}
311
312// One checklist line for a task: done, doing, to do, dropped.
313function taskLine(t, key, status, subject, room) {
314  const { Text } = t
315  const text = cut(clean(subject), room)
316  if (status === 'completed') return Text({ key, dimColor: true, strikethrough: true, children: [`${BOX_DONE} ${text}`] })
317  if (status === 'in_progress') return Text({ key, color: 'claude', bold: true, children: [`${ARROW} ${text}`] })
318  if (status === 'deleted') return Text({ key, dimColor: true, children: [`${CROSS} ${text}`] })
319  return Text({ key, children: [`${BOX_EMPTY} ${text}`] })
320}
321
322// TaskCreate, TaskUpdate, TaskList: the session's task list as the panel's
323// checklist. A task's subject is learnt from its creation's result, drawn
324// earlier in the same transcript; an update of one never seen says its number.
325function taskCard(t, p, tasks, columns) {
326  const { Box, Text } = t
327  const input = p.input || {}
328  const output = p.output && typeof p.output === 'object' ? p.output : {}
329  const room = Math.max(8, columns - 6)
330  const dot = Text({ color: dotColor(p), children: [`${DOT} `] })
331  if (p.tool === 'TaskCreate') {
332    if (output.task && output.task.id) tasks.set(String(output.task.id), { subject: output.task.subject || input.subject, activeForm: input.activeForm })
333    return Box({ flexDirection: 'row', children: [dot, taskLine(t, 'task', 'pending', input.subject || '', room)] })
334  }
335  if (p.tool === 'TaskUpdate') {
336    const id = String(input.taskId || '')
337    const known = tasks.get(id) || {}
338    if (input.subject) tasks.set(id, { ...known, subject: input.subject })
339    const subject = input.subject || known.subject || `task ${id}`
340    const status = input.status || 'pending'
341    const shown = status === 'in_progress' ? (input.activeForm || known.activeForm || subject) : subject
342    if (!input.status) return Box({ flexDirection: 'row', children: [dot, Text({ dimColor: true, children: [cut(`updated ${clean(subject)}`, room)] })] })
343    return Box({ flexDirection: 'row', children: [dot, taskLine(t, 'task', status, shown, room)] })
344  }
345  const list = Array.isArray(output.tasks) ? output.tasks : []
346  const done = list.filter((x) => x && x.status === 'completed').length
347  const rows = [Box({ flexDirection: 'row', children: [dot, Text({ bold: true, children: ['Tasks'] }), Text({ dimColor: true, children: [`  ${done}/${list.length} done`] })] })]
348  list.slice(0, 20).forEach((task, i) => {
349    if (task) rows.push(Box({ key: `task-${i}`, paddingLeft: 2, children: [taskLine(t, `line-${i}`, task.status, task.subject, room - 2)] }))
350  })
351  return Box({ flexDirection: 'column', children: rows })
352}
353
354// Hunks as a unified diff, cut to `most` lines; a cut hunk gets its header
355// counted again from what is kept, so it still parses.
356function diffSource(patch, most) {
357  const parts = []
358  let used = 0
359  let left = 0
360  for (const hunk of patch) {
361    const lines = (hunk.lines || []).map((line) => clean(line, true).replace(/\n/g, ''))
362    if (used >= most) { left += lines.length; continue }
363    const kept = lines.slice(0, most - used)
364    left += lines.length - kept.length
365    used += kept.length
366    const oldLines = kept.filter((l) => !l.startsWith('+') && !l.startsWith('\\')).length
367    const newLines = kept.filter((l) => !l.startsWith('-') && !l.startsWith('\\')).length
368    parts.push(`@@ -${hunk.oldStart},${oldLines} +${hunk.newStart},${newLines} @@\n${kept.join('\n')}`)
369  }
370  return { source: parts.join('\n'), left }
371}
372
373function diffCard(t, source, path, left) {
374  const { Box, Text, Code } = t
375  const children = [Code({ source, format: 'diff', path, wrap: 'truncate-end' })]
376  if (left > 0) children.push(Text({ dimColor: true, children: [`${ELLIPSIS} ${left} more line${left === 1 ? '' : 's'}`] }))
377  return Box({ flexDirection: 'column', marginLeft: 2, borderStyle: 'round', borderColor: 'subtle', paddingX: 1, children })
378}
379
380// An edit's or a write's result: its diff, framed, as the panel draws one.
381function editResult(t, tool, output) {
382  if (!output || typeof output !== 'object') return null
383  const path = typeof output.filePath === 'string' ? output.filePath : undefined
384  const patch = Array.isArray(output.structuredPatch) ? output.structuredPatch : []
385  if (patch.length) {
386    const { source, left } = diffSource(patch, tool === 'Write' ? WRITE_LINES : DIFF_LINES)
387    return source ? diffCard(t, source, path, left) : null
388  }
389  if (tool === 'Write' && typeof output.content === 'string') {
390    const lines = output.content.replace(/\n$/, '').split('\n')
391    const { source, left } = diffSource([{ oldStart: 0, newStart: 1, lines: lines.map((l) => `+${l}`) }], WRITE_LINES)
392    return diffCard(t, source, path, left)
393  }
394  return null
395}
396
397function modelName(model) {
398  const bare = String(model || '').replace(/\[.*\]$/, '').replace(/^claude-/, '').replace(/-\d{8}$/, '')
399  const m = /^([a-z]+)-(\d+)(?:-(\d+))?$/.exec(bare)
400  if (m) return `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2]}${m[3] ? '.' + m[3] : ''}`
401  return bare ? bare[0].toUpperCase() + bare.slice(1) : ''
402}
403
404function seconds(ms) {
405  const s = Math.max(0, Math.round(ms / 1000))
406  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
407}
408
409export function registerLook(on) {
410  // The person's prompt in a rounded box, as the panel's bubble; other user
411  // rows (notifications, peers) and the ctrl+o transcript keep the engine's.
412  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
413    try {
414      const p = e.props || {}
415      if (p.isExpanded || (p.origin && !TYPED.has(p.origin.kind)) || p.task || p.from) return next(e)
416      const state = await lookState($, e)
417      if (!state) return next(e)
418      const text = clean(p.text, true).replace(/\s+$/, '')
419      if (!text) return next(e)
420      const { Box, Text } = $.ui.resolve(e)
421      return Box({
422        flexDirection: 'row',
423        marginTop: 1,
424        children: [Box({ borderStyle: 'round', borderColor: 'subtle', paddingX: 1, flexShrink: 1, children: [Text({ children: [text] })] })],
425      })
426    } catch (_) {
427      return next(e)
428    }
429  })
430
431  // A run of reads and searches unfolds: each call is its own row, as in the
432  // panel, and the rows it unfolds into are remembered for their summaries.
433  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
434    try {
435      const state = await lookState($, e)
436      if (!state) return next(e)
437      for (const call of (e.props && e.props.calls) || []) {
438        if (call && call.tool_use_id) state.grouped.add(call.tool_use_id)
439      }
440      if (e.props.isExpanded) return next(e)
441      return next({ ...e, props: { ...e.props, isExpanded: true } })
442    } catch (_) {
443      return next(e)
444    }
445  })
446
447  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
448    try {
449      const p = e.props || {}
450      if (!DRAWN.has(String(p.tool))) return next(e)
451      const state = await lookState($, e)
452      if (!state) return next(e)
453      const t = $.ui.resolve(e)
454      const columns = (e.viewport && e.viewport.columns) || 80
455      // A standalone row stands a line apart, as the engine's does; the rows of
456      // an unfolded group sit together and carry their own result.
457      const grouped = state.grouped.has(p.tool_use_id)
458      const rows = []
459      if (p.tool === 'TodoWrite') rows.push(todoCard(t, p, columns))
460      else if (TASKS.has(p.tool)) rows.push(taskCard(t, p, state.tasks, columns))
461      else rows.push(headerRow(t, p, state.cwd, columns))
462      if (p.isErrored && !p.isInterrupted) {
463        // The engine draws an errored call's text inside its own row, and raises
464        // no result site for it: the row says it, in the error colour.
465        const said = typeof p.output === 'string' ? p.output : summaryOf(p.tool, p.output)
466        const lines = clean(said, true).replace(/<\/?tool_use_error>/g, '').trim().split('\n').filter(Boolean)
467        lines.slice(0, 3).forEach((line, i) => rows.push(t.Text({
468          key: `error-${i}`, color: 'error', children: [`  ${i === 0 ? ELBOW : '  '}${cut(line, Math.max(8, columns - 6))}`],
469        })))
470      } else if (grouped && !p.isRunning && !TASKS.has(p.tool)) {
471        const summary = summaryOf(p.tool, p.output)
472        if (summary) rows.push(summaryRow(t, summary, columns))
473      }
474      return t.Box({ flexDirection: 'column', marginTop: grouped ? 0 : 1, children: rows })
475    } catch (_) {
476      return next(e)
477    }
478  })
479
480  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
481    try {
482      const p = e.props || {}
483      if (p.isErrored || !DRAWN.has(String(p.tool)) || p.tool === 'Bash') return next(e)
484      const state = await lookState($, e)
485      if (!state) return next(e)
486      const t = $.ui.resolve(e)
487      const columns = (e.viewport && e.viewport.columns) || 80
488      if (TASKS.has(p.tool)) return t.Box({ flexDirection: 'column', children: [] })
489      if (p.tool === 'Edit' || p.tool === 'MultiEdit' || p.tool === 'Write') {
490        return editResult(t, p.tool, p.output) || next(e)
491      }
492      const summary = summaryOf(p.tool, p.output)
493      return summary ? summaryRow(t, summary, columns) : next(e)
494    } catch (_) {
495      return next(e)
496    }
497  })
498
499  // A calmer spinner: one plain word for what the turn does; the engine keeps
500  // its glyph, the elapsed time and the tokens.
501  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
502    try {
503      const p = e.props || {}
504      if (p.message) return next(e)
505      const state = await lookState($, e)
506      if (!state) return next(e)
507      return next({ ...e, props: { ...p, word: DOING[p.mode] || 'Working', suffix: ELLIPSIS } })
508    } catch (_) {
509      return next(e)
510    }
511  })
512
513  // The line that closes a turn, quiet: how long it took, dim.
514  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
515    try {
516      const state = await lookState($, e)
517      if (!state) return next(e)
518      const { Text } = $.ui.resolve(e)
519      return Text({ dimColor: true, children: [`  ${ELBOW}done in ${seconds(e.props.durationMs || 0)}`] })
520    } catch (_) {
521      return next(e)
522    }
523  })
524
525  // The panel's footer: the model and the context's fill, after the engine's
526  // own hint, whose pills stay live.
527  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
528    try {
529      const state = await lookState($, e)
530      if (!state) return next(e)
531      const parts = []
532      try { parts.push(modelName(await $.session.model())) } catch (_) {}
533      try {
534        const usage = await $.session.usage()
535        const percent = usage && usage.context ? usage.context.percent : undefined
536        if (typeof percent === 'number') parts.push(`${percent}% context`)
537      } catch (_) {}
538      const tail = parts.filter(Boolean).join(MIDDOT)
539      return tail ? next({ ...e, props: { ...e.props, tail } }) : next(e)
540    } catch (_) {
541      return next(e)
542    }
543  })
544}
545