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…

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.
| Color | State | Meaning |
|---|---|---|
| 🟠 blinking amber | needs 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. |
| 🟢 green | done (ready) | The turn has finished: there is an answer to read. |
| 🔴 solid red | stopped (failed) | The turn stopped without producing anything: rate limit, overload, authentication error. |
| 🟡 yellow | working | Claude is processing or running tools. |
| 🔵 soft blue | paused (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 ring | resting (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).
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.
At most a few, and only when they say something:
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).
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 Code | Codex | |
|---|---|---|
| Where its sessions live | ~/.claude | ~/.codex |
| Context ring | measured denominator, with a confidence | window declared by the harness |
| Usage left | not on disk anywhere | in the card |
| Amber says what is being asked | no, by our choice: see below | yes, Bash: git push origin main |
| Red, when a turn fails | yes | never: Codex publishes no error event at all |
| Blue, while background agents work | yes | yes |
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.
| Surface | Discovered | Liveness | State | Focus | Evidence |
|---|---|---|---|---|---|
| Claude Code, VS Code extension | yes | window lock | full | window | unit · end-to-end · live |
| Claude Code, terminal | yes | session file and pid | full | terminal seat | unit · end-to-end · live |
| Claude Code, over the tunnel | yes | probe on the far side | full | Remote-SSH window | unit · end-to-end · live |
| Claude Code, desktop app — local session | index and transcript | the app is running | derived from the transcript, never red, never amber | raises the app | unit · end-to-end |
| Claude Code, desktop app — cloud session | no | — | |||
| Codex, CLI | open rollout | open descriptor | hooks, never red | terminal seat | unit · end-to-end · live |
| Codex, VS Code extension | open rollout | open descriptor | hooks, never red | window | unit · live discovery |
| Codex, ChatGPT app | open rollout | open descriptor | presence only | raises the app | unit · 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:
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.
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.
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.
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.
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.
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
hooks/register.js 729 lines1// 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}
729hooks/lamps.js 178 lines1// 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}
178hooks/look.js 545 lines1// 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