A web cockpit and a /tower pane over every Claude Code agent across your repos: see who needs you, approve and answer, send prompts and slash commands, start…

A Claude Code plugin: one cockpit for every Claude Code agent on your machine, across all your repos. See who needs you, approve and answer, send prompts and slash commands, start agents in their own git worktrees, and watch what each one changes.
Try the live demo (in your browser, on sample data) · Site · Changelog
<img src="site/images/approve-dark.webp" alt="The cockpit's Needs you cards: allowing an agent's git push and answering another agent's question">
claude plugin marketplace add alaturqua/tower-mods && claude plugin install tower@tower
On Windows, run the two commands one after the other. PowerShell 5 has no &&, and Command Prompt has no ;:
claude plugin marketplace add alaturqua/tower-mods
claude plugin install tower@tower
Install it everywhere: every session then reports to the cockpit and can be steered from it. Sessions that were already running pick it up after a restart or /reload-plugins.
Then type /cockpit in any session. It starts the cockpit in the background and opens your browser.
After an update, /cockpit swaps a cockpit of an older version for the new one by itself. /cockpit restart replaces it on demand and /cockpit stop stops it; from a shell, node plugins/tower/cockpit/server.mjs --restart or --stop.
Needs Node.js 18 or newer for the cockpit, and the GitHub CLI (gh) for pull requests. Mods draw nothing in the VS Code chat panel; for /tower and the status line, run Claude Code in a terminal (VS Code's integrated terminal works) or the desktop app's Code tab.
| Area | What it does |
|---|---|
| Needs you | Permission requests and questions as cards, oldest first: the agent's reason, a diff for edits, a badge on risky commands (pushes, deletes, deploys). Allow, deny or answer with a click, or A / D / 1–4. |
| Fleet | Every session as a table, cards or a timeline (your choice is remembered): state, what it's doing, lines changed, PR and CI, context fill, cost. Stuck, looping and context-full agents are flagged. |
| Repositories and workstreams | The left rail groups sessions by repo and git worktree. + New workstream starts a background agent in a fresh worktree on its own branch (claude --bg -w). The ⋯ menus rename or remove repos and worktrees. |
| The selected agent | Its live activity, changed files, and pull request (squash-merge when it's green). Send it a prompt, or type / for its slash commands. Jump to its window, or stop it if it runs in the background. |
Select several agents to send them all the same prompt, or stop them. Everything follows System, Light or Dark.
plugins/tower/cockpit, no dependencies). It listens on 127.0.0.1 only. The link /cockpit opens carries a one-time token that the browser trades for a same-site cookie. The server refuses other host names and requests from other web pages.claude agents --json for the live sessions, each session's status file in ~/.claude/tower/sessions/, git in each working folder, and gh for pull requests.~/.claude/tower/inbox/<session>.jsonl. The plugin in that session picks them up within a second and runs them as if typed there. The cockpit shows "Queued" until it has. A session started before installing can't receive until it restarts, and the cockpit says so.remoteAnswers to always to answer every session from the cockpit.The same plugin works without the browser:
/tower opens a pane beside your conversation with every session. Allow, deny, answer, send a prompt, jump, or launch a background agent. The same actions work as commands: /tower list, /tower send <session> <prompt>, /tower launch <repo path> <task>.osascript, Linux notify-send).1 needs you · tower-mods ⎇ main* · Opus 5.5 · high · auto · ctx 42% · $1.23: other sessions waiting on you, then folder, branch (* when there are uncommitted changes), model, effort, permission mode, context fill and cost.Change these in /config or under pluginConfigs.tower.options in ~/.claude/settings.json:
| Option | Default | Meaning |
|---|---|---|
notifications | true | Desktop notifications at all |
notifyOnDone | true | Notify when a turn ends after minTurnSeconds |
minTurnSeconds | 30 | Shorter turns end quietly |
notifyOnIdle | false | Also notify on Claude Code's "still waiting" reminders |
remoteAnswers | launched | Which sessions send permission prompts and questions to the cockpit and tower: launched, always, never |
inboxSeconds | 1 | How often a session checks for prompts and answers from the cockpit; 0 turns it off |
pollSeconds | 3 | How often /tower refreshes, once it has run in a session |
statusLine | true | The status line at all |
showBranch, showContext, showCost | true | Parts of the status line |
statusSeconds | 5 | How often branch, context and cost are re-read between turns |
The status line learns the permission mode and effort when Claude Code reports them (each prompt, tool call and turn end), so a Shift+Tab switch shows with your next prompt.
Add this working copy as a marketplace. Claude Code then reads the plugin straight from the folder:
claude plugin marketplace add D:\Projects\tower-mods
claude plugin install tower@tower
Edit, then /reload-plugins in a session, or start one with claude --plugin-dir D:\Projects\tower-mods\plugins\tower for hot reload. Run the cockpit from the folder with node plugins/tower/cockpit/server.mjs --open.
The plugin is one hooks module (hooks/register.ts) with three parts, each in its own file:
beacon.ts: status file, notifications, inbox, remote answers;pane.tsx: /tower and /cockpit;strip.ts: the status line.A part never passes $ to another file; the engine refuses that.
The cockpit's page is built from the approved design: cockpit/web/mockup.dc.html plus cockpit/web/logic.js, through node cockpit/web/build.mjs. The build writes the cockpit's page and the site's live demo, which runs the same page against site/demo/sample.js.
claude plugin validate plugins/tower
claude plugin test plugins/tower
npm i --no-save puppeteer-core # for the real-browser tests, which skip without it
node --test plugins/tower/cockpit/test/*.test.mjs
CI runs all of these on every push.
node scripts/release.mjs 0.3.0
git push --follow-tags
The script bumps the version in both manifests, regenerates CHANGELOG.md with git-cliff, commits and tags. The pushed tag publishes a GitHub release with the notes for that version.
Apache-2.0, see LICENSE.
hooks/register.ts 14 lines1import type { Register } from 'claude-code'
2
3import { registerBeacon } from './beacon'
4import { registerPane } from './pane'
5import { registerStrip } from './strip'
6
7// One plugin, three parts, each with its own switches in /config: beacon in every
8// session, the /tower pane where you steer from, and the status line.
9export const register: Register = (on, options) => {
10 registerBeacon(on, options)
11 registerPane(on, options)
12 registerStrip(on, options)
13}
14hooks/beacon.ts 420 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BeaconPending } from '../types'
5import { currentRun } from './strip'
6
7type $ = EngineInterface
8type State = 'idle' | 'working' | 'needs-input' | 'done'
9// `at`, when the cockpit sent it; a message through the tower pane has none.
10type TowerMessage =
11 | { v: 1; kind: 'prompt'; text: string; at?: string }
12 | { v: 1; kind: 'answer'; pendingId: string; allow?: boolean; text?: string; at?: string }
13// One line of the cockpit's live activity: who did what, newest last.
14type Activity = { t: string; kind: 'you' | 'tool' | 'say' | 'wait' | 'sent' | 'fail'; text: string }
15
16// Two alerts for the same moment (AskUserQuestion's tool call and the Notification
17// hook it raises) collapse into the first.
18const DEDUPE_MS = 5000
19const QUIET_TYPES = new Set(['idle_prompt', 'auth_success'])
20const TOWER_MARK = '[tower] '
21const ACTIVITY_MAX = 30
22const LOOP_AFTER = 3
23const INBOX_MS = 1000
24
25const pending = atom({ plugin: 'tower', key: 'pending' } as const, null as BeaconPending | null)
26const approvals = atom({ plugin: 'tower', key: 'approvals' } as const, [] as string[])
27
28const WIN_TOAST = `
29$ErrorActionPreference = 'Stop'
30[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
31[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
32$t = [Security.SecurityElement]::Escape($env:BEACON_TITLE)
33$b = [Security.SecurityElement]::Escape($env:BEACON_BODY)
34$x = New-Object Windows.Data.Xml.Dom.XmlDocument
35$x.LoadXml("<toast><visual><binding template='ToastGeneric'><text>$t</text><text>$b</text></binding></visual><audio src='ms-winsoundevent:Notification.Reminder'/></toast>")
36$app = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'
37[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($app).Show([Windows.UI.Notifications.ToastNotification]::new($x))
38`
39
40let notifyOnDone = true
41let minTurnMs = 30000
42let notifyOnIdle = false
43let notifications = true
44let remoteAnswers = 'launched'
45// Whether this session sends its dialogs to the tower; settled at session.start.
46let isRemote = false
47let label = ''
48let lastAlertAt = 0
49// The tower's preview of the conversation: what was last asked, and how the last answer ended.
50let lastPrompt: string | null = null
51let lastAnswer: string | null = null
52const PREVIEW_CHARS = 1500
53let activity: Activity[] = []
54let lastState: State = 'idle'
55let lastMessage: string | null = null
56// The same call failing again and again: its key, and how many times in a row.
57let failing = { key: '', count: 0, text: '' }
58let inboxBusy = false
59// Inbox lines this session has taken, for the cockpit to tell "queued" from "picked up".
60let inboxSeen = 0
61let inboxOn = false
62// A message sent before this session could read it (it was not running yet, or ran an
63// older Tower) is stale by the time it arrives; the grace covers a prompt sent at launch.
64let startedAt = 0
65const STALE_GRACE_MS = 30000
66
67async function getLabel($: $) {
68 if (label) return label
69 const repo = await $.session.repo().catch(() => null)
70 const dir = repo?.root ?? (await $.session.cwd())
71 label = dir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || dir
72 return label
73}
74
75async function towerDir($: $) {
76 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
77 return `${home}/.claude/tower`
78}
79
80async function statusPath($: $) {
81 return `${await towerDir($)}/sessions/${await $.session.id()}.json`
82}
83
84async function note($: $, kind: Activity['kind'], text: string) {
85 const t = new Date(await $.clock.now()).toISOString()
86 activity = [...activity, { t, kind, text: text.replace(/\s+/g, ' ').slice(0, 200) }].slice(-ACTIVITY_MAX)
87}
88
89// The status file the tower pane and the cockpit read for every session. A call without
90// a state keeps the last one, so a tool call can refresh the activity without changing it.
91async function report($: $, state?: State, message?: string | null) {
92 if (state) lastState = state
93 if (message !== undefined) lastMessage = message
94 try {
95 const repo = await $.session.repo().catch(() => null)
96 const usage = await $.session.usage().catch(() => undefined)
97 const waiting = await read($, pending)
98 const run = currentRun()
99 await $.fs.write(await statusPath($), JSON.stringify({
100 sessionId: await $.session.id(),
101 label: await getLabel($),
102 cwd: await $.session.cwd(),
103 remote: repo?.remote ?? null,
104 model: await $.session.model(),
105 mode: run.mode,
106 effort: run.effort,
107 contextPercent: usage?.context.percent ?? null,
108 costUsd: usage?.cost?.usd ?? null,
109 state: lastState,
110 message: lastMessage,
111 pending: waiting ? { ...waiting, key: undefined } : null,
112 remoteAnswers: isRemote,
113 // The cockpit may send here only when this session reads its inbox.
114 canReceive: inboxOn,
115 inboxSeen,
116 health: failing.count >= LOOP_AFTER ? `Looping: ${failing.text} failed ${failing.count}× in a row` : null,
117 lastPrompt,
118 lastAnswer,
119 activity,
120 updatedAt: new Date(await $.clock.now()).toISOString(),
121 }, null, 2))
122 } catch {
123 // A status file is best effort; never let it break the session.
124 }
125}
126
127async function desktopNotify($: $, title: string, body: string) {
128 const env = { BEACON_TITLE: title, BEACON_BODY: body.slice(0, 240) }
129 if ((await $.env.get('OS')) === 'Windows_NT') {
130 await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', WIN_TOAST], { env, timeoutMs: 15000 })
131 return
132 }
133 const mac = await $.process.run(
134 ['osascript', '-e', 'display notification (system attribute "BEACON_BODY") with title (system attribute "BEACON_TITLE")'],
135 { env },
136 ).catch(() => undefined)
137 if (mac?.exitCode !== 0) await $.process.run(['notify-send', title, body], { env }).catch(() => undefined)
138}
139
140// Fire and forget: a notification must never hold up the dialog it announces.
141async function alert($: $, kind: string, body: string, state: State) {
142 const now = await $.clock.now()
143 void report($, state, body)
144 if (!notifications || now - lastAlertAt < DEDUPE_MS) return
145 lastAlertAt = now
146 const title = `${kind} · ${await getLabel($)}`
147 void desktopNotify($, title, body).catch(err => $.ui.log(`beacon: notification failed: ${String(err)}`))
148}
149
150// What the tower shows for a call: the command or path, else the input itself.
151function describeInput(input: unknown) {
152 const fields = (input ?? {}) as Record<string, unknown>
153 const main = fields.command ?? fields.file_path ?? fields.path ?? fields.url ?? fields.pattern
154 return (typeof main === 'string' ? main : JSON.stringify(input) ?? '').slice(0, 300)
155}
156
157// For an edit, the change as diff lines the cockpit can draw; nothing for other tools.
158function diffOf(tool: string, input: unknown): BeaconPending['diff'] {
159 const fields = (input ?? {}) as Record<string, unknown>
160 const edits = Array.isArray(fields.edits) ? fields.edits as Record<string, unknown>[] : [fields]
161 const lines: NonNullable<BeaconPending['diff']> = []
162 for (const edit of edits) {
163 const before = typeof edit.old_string === 'string' ? edit.old_string : undefined
164 const after = typeof edit.new_string === 'string' ? edit.new_string : (tool === 'Write' && typeof fields.content === 'string' ? fields.content : undefined)
165 if (after === undefined) continue
166 lines.push([`@@ ${typeof fields.file_path === 'string' ? fields.file_path.split(/[\\/]/).pop() : ''} @@`, 'hunk'])
167 for (const line of (before ?? '').split('\n').filter(Boolean)) lines.push([`- ${line}`, 'del'])
168 for (const line of after.split('\n').filter(Boolean)) lines.push([`+ ${line}`, 'add'])
169 }
170 return lines.length ? lines.slice(0, 24) : undefined
171}
172
173// The agent's own last words before it asked: the reason the cockpit shows on the card.
174async function reasonFor($: $) {
175 const rows = await $.session.messages().catch(() => [])
176 for (let i = rows.length - 1; i >= 0; i--) {
177 const row = rows[i]
178 if (row?.role === 'assistant' && row.text?.trim()) return row.text.trim().split('\n').filter(Boolean).pop()?.slice(0, 200)
179 }
180 return undefined
181}
182
183async function waitFor($: $, item: Omit<BeaconPending, 'id' | 'since'>, alertKind: string, alertBody: string) {
184 const now = await $.clock.now()
185 const why = await reasonFor($)
186 await update($, pending, () => ({ ...item, id: `p-${now}`, since: new Date(now).toISOString(), ...(why ? { why } : {}) }))
187 await note($, 'wait', alertBody)
188 // Written before the deny returns, so the tower sees the wait as soon as the model does.
189 await report($, 'needs-input', alertBody)
190 await alert($, alertKind, `${alertBody} (answer in the tower)`, 'needs-input')
191}
192
193// A `[tower] {json}` payload anywhere in a delivery, whatever envelope wraps it.
194function parseTower(text: string): TowerMessage | undefined {
195 const at = text.indexOf(TOWER_MARK)
196 if (at < 0) return undefined
197 return parseMessage(text.slice(text.indexOf('{', at), text.lastIndexOf('}') + 1))
198}
199
200function parseMessage(body: string): TowerMessage | undefined {
201 try {
202 const msg = JSON.parse(body)
203 return msg?.v === 1 && (msg.kind === 'prompt' || msg.kind === 'answer') ? msg : undefined
204 } catch {
205 return undefined
206 }
207}
208
209// Queued as the person's own words; runs once the session is idle. Never awaited:
210// inside a running turn the call waits for that turn.
211function submit($: $, text: string) {
212 // Our own submit skips our prompt.submit hook, so the preview is set here.
213 lastPrompt = text.slice(0, 500)
214 void $.prompt.submit({ text, asUser: true }).catch(err => $.ui.log(`beacon: could not submit the tower's prompt: ${String(err)}`))
215}
216
217async function answer($: $, msg: Extract<TowerMessage, { kind: 'answer' }>) {
218 const waiting = await read($, pending)
219 if (!waiting || waiting.id !== msg.pendingId) {
220 $.ui.log(`beacon: dropped a tower answer for ${msg.pendingId}; nothing like it is pending`)
221 return
222 }
223 await update($, pending, () => null)
224 if (waiting.kind === 'permission') {
225 if (msg.allow) {
226 await update($, approvals, keys => [...keys, waiting.key ?? ''])
227 await note($, 'sent', `Approved: ${waiting.detail ?? waiting.title}`)
228 submit($, `Approved in the tower: retry ${waiting.title} ${waiting.detail ?? ''}`.trim())
229 } else {
230 await note($, 'sent', `Denied: ${waiting.detail ?? waiting.title}`)
231 submit($, `Denied in the tower: do not run ${waiting.title} ${waiting.detail ?? ''}. Continue another way or ask.`)
232 }
233 } else {
234 await note($, 'sent', `Answered: ${msg.text ?? ''}`)
235 submit($, `Answer to your question "${waiting.title}": ${msg.text ?? ''}`)
236 }
237 await report($, 'working')
238}
239
240// A slash command from the tower runs as if typed (`/compact`, `/model sonnet`); any other
241// text is a prompt. Never awaited: the command queues until the session is idle.
242function runCommand($: $, text: string) {
243 const [, command = '', args = ''] = /^\/(\S+)\s*([\s\S]*)$/.exec(text.trim()) ?? []
244 void $.command.run({ command, args } as never).catch(err => $.ui.log(`beacon: /${command} from the tower failed: ${String(err)}`))
245}
246
247// The session's slash commands, for the cockpit's send box to suggest; written once at start.
248async function publishCommands($: $) {
249 try {
250 const list = await $.command.list()
251 const commands = list.map(c => ({ name: c.name, description: c.description.slice(0, 120) }))
252 await $.fs.write(`${await towerDir($)}/commands/${await $.session.id()}.json`, JSON.stringify(commands))
253 } catch {
254 // Suggestions are a convenience; the commands still run when typed in full.
255 }
256}
257
258// One instruction from the tower, by message or by inbox line.
259async function obey($: $, msg: TowerMessage) {
260 if (msg.kind === 'prompt' && msg.text.trim().startsWith('/')) {
261 await note($, 'sent', msg.text.trim())
262 runCommand($, msg.text)
263 await report($)
264 } else if (msg.kind === 'prompt') {
265 await note($, 'sent', msg.text)
266 submit($, msg.text)
267 await report($)
268 } else {
269 await answer($, msg)
270 }
271}
272
273// The cockpit appends instructions to inbox/<session>.jsonl; the lines already obeyed
274// are counted in the store, so a reload or a restart never obeys one twice.
275async function checkInbox($: $) {
276 if (inboxBusy) return
277 inboxBusy = true
278 try {
279 const id = await $.session.id()
280 const path = `${await towerDir($)}/inbox/${id}.jsonl`
281 if (!(await $.fs.exists(path).catch(() => false))) return
282 const lines = String(await $.fs.read(path)).split('\n').filter(line => line.trim())
283 const seenKey = `inbox:${id}`
284 const seen = Number((await $.store.get(seenKey)) ?? 0)
285 inboxSeen = Math.min(seen, lines.length)
286 if (lines.length <= seen) return
287 await $.store.set(seenKey, lines.length)
288 inboxSeen = lines.length
289 for (const line of lines.slice(seen)) {
290 const msg = parseMessage(line)
291 const at = msg?.at ? Date.parse(msg.at) : NaN
292 if (msg && !(at < startedAt - STALE_GRACE_MS)) await obey($, msg)
293 }
294 } catch (err) {
295 $.ui.log(`beacon: could not read the cockpit's inbox: ${String(err)}`)
296 } finally {
297 inboxBusy = false
298 }
299}
300
301// The part in every session: status file, notifications, and the receiving end of the tower.
302export const registerBeacon: Register = (on, options) => {
303 notifyOnDone = options.notifyOnDone !== false
304 minTurnMs = Number(options.minTurnSeconds ?? 30) * 1000
305 notifyOnIdle = options.notifyOnIdle === true
306 notifications = options.notifications !== false
307 remoteAnswers = String(options.remoteAnswers ?? 'launched')
308 const inboxMs = Number(options.inboxSeconds ?? 1) * 1000
309
310 // The plugin's one unmatched session.start; the pane's and the status line's match every cwd.
311 on('session.start', async ($, e, next) => {
312 isRemote = remoteAnswers === 'always' || (remoteAnswers === 'launched'
313 && await $.fs.exists(`${await towerDir($)}/launched/${await $.session.id()}.json`).catch(() => false))
314 startedAt = await $.clock.now()
315 inboxOn = inboxMs > 0
316 void report($, 'idle')
317 void publishCommands($)
318 if (inboxOn) $.clock.every(inboxMs || INBOX_MS, () => { void checkInbox($) })
319 return next(e)
320 })
321
322 on('prompt.submit', async ($, e, next) => {
323 if (!(e.origin.kind === 'plugin' && e.origin.name === 'tower')) {
324 lastPrompt = e.text.slice(0, 500)
325 await note($, 'you', e.text)
326 }
327 return next(e)
328 })
329
330 on('turn.start', async ($, e, next) => {
331 await update($, pending, () => null)
332 void report($, 'working')
333 return next(e)
334 })
335
336 on('session.receive', async ($, e, next) => {
337 const msg = parseTower(e.text)
338 if (!msg) return next(e)
339 await obey($, msg)
340 return { consumed: 'a tower instruction' }
341 })
342
343 on('tool.check', async ($, e, next) => {
344 const verdict = await next(e)
345 if (!isRemote || verdict.decision !== 'ask' || !e.tool_use_id) return verdict
346 const key = `${e.tool}:${JSON.stringify(e.input)}`
347 const keys = await read($, approvals)
348 if (keys.includes(key)) {
349 await update($, approvals, list => list.filter(k => k !== key))
350 return { decision: 'allow', reason: 'Approved in the tower' }
351 }
352 const detail = describeInput(e.input)
353 const diff = diffOf(e.tool, e.input)
354 await waitFor($, { kind: 'permission', title: e.tool, detail, key, ...(diff ? { diff } : {}) }, 'Permission', `${e.tool}: ${detail}`)
355 return { decision: 'deny', reason: 'Waiting for approval in the tower. End your turn now; you will be told when to retry.' }
356 }).catch(($, e, next) => next(e))
357
358 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
359 const first = e.questions?.[0]
360 if (first && isRemote) {
361 const options = first.options.map(o => o.label)
362 await waitFor($, { kind: 'question', title: first.question, options }, 'Question', first.question)
363 return { deny: 'The question was sent to the person in the tower. End your turn now; their answer arrives as the next message.' }
364 }
365 if (first) await alert($, 'Question', first.question, 'needs-input')
366 const result = await next(e)
367 void report($, 'working')
368 return result
369 }).catch(($, e, next) => next(e))
370
371 // Every main-loop tool call: one line of activity, and a count of the same call failing in a row.
372 on('tool.call', async ($, e, next) => {
373 const result = await next(e)
374 if (e.agentId || e.tool === 'AskUserQuestion') return result
375 // A tool.call carries the tool's arguments beside its envelope fields.
376 const { tool, tool_use_id: _id, agentId: _agent, ...args } = e as unknown as Record<string, unknown>
377 const text = `${String(tool)} ${describeInput(args)}`.trim()
378 const failed = 'isError' in result && result.isError === true
379 if (failed) {
380 const key = `${String(tool)}:${JSON.stringify(args)}`
381 failing = key === failing.key ? { key, count: failing.count + 1, text } : { key, count: 1, text }
382 } else {
383 failing = { key: '', count: 0, text: '' }
384 }
385 await note($, failed ? 'fail' : 'tool', text)
386 void report($)
387 return result
388 }).catch(($, e, next) => next(e))
389
390 on('classic.Notification', async ($, e, next) => {
391 if (!e.agent_id && (notifyOnIdle || !QUIET_TYPES.has(e.notification_type))) {
392 const kind = e.notification_type === 'permission_prompt' ? 'Permission' : 'Needs you'
393 await alert($, kind, e.message, 'needs-input')
394 }
395 return next(e)
396 }).catch(($, e, next) => next(e))
397
398 on('turn.complete', async ($, e, next) => {
399 const result = await next(e)
400 if (e.agentId) return result
401 if (e.answer.trim()) {
402 lastAnswer = e.answer.trim().slice(-PREVIEW_CHARS)
403 await note($, 'say', e.answer.trim().split('\n').filter(Boolean).pop() ?? '')
404 }
405 const waiting = await read($, pending)
406 await report($, waiting ? 'needs-input' : 'done', waiting ? waiting.title : e.answer.slice(0, 200))
407 if (!waiting && notifyOnDone && !e.isAborted && e.durationMs >= minTurnMs) {
408 const secs = Math.round(e.durationMs / 1000)
409 await alert($, 'Done', `${secs}s · ${e.answer.split('\n')[0]?.slice(0, 160) || 'Turn finished'}`, 'done')
410 }
411 return result
412 })
413
414 on('session.end', async ($, e, next) => {
415 const path = await statusPath($).catch(() => undefined)
416 if (path) await $.fs.write(path, JSON.stringify({ sessionId: await $.session.id(), state: 'ended' })).catch(() => {})
417 return next(e)
418 })
419}
420hooks/pane.tsx 423 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TowerSession } from '../types'
5import { COLOR, MARK, ago, columns, envelope, merge, tail } from './model'
6import type { AgentRow, StatusFile, TowerMessage } from './model'
7import { setWaiting } from './strip'
8
9type $ = EngineInterface
10
11const PANE = 'tower'
12const USAGE = 'Usage: /tower · /tower list · /tower send <session> <prompt> · /tower launch <repo path> <task> · /tower add <repo path>'
13const COCKPIT_PS = `Start-Process -WindowStyle Hidden -FilePath node -ArgumentList (@('"' + $env:TOWER_SERVER + '"') + ($env:TOWER_FLAGS -split ' ')) `
14// A session the tower starts is its own, not a child of the tower's.
15const CHILD_ENV = { CLAUDECODE: '', CLAUDE_CODE_SESSION_ID: '', CLAUDE_CODE_CHILD_SESSION: '', CLAUDE_PID: '' }
16
17const sessions = atom({ plugin: 'tower', key: 'sessions' } as const, [] as TowerSession[])
18const selected = atom({ plugin: 'tower', key: 'selected' } as const, null as string | null)
19const view = atom({ plugin: 'tower', key: 'view' } as const, 'list' as 'list' | 'launch')
20const launchRepo = atom({ plugin: 'tower', key: 'launchRepo' } as const, null as string | null)
21const failure = atom({ plugin: 'tower', key: 'error' } as const, null as string | null)
22
23let pollMs = 3000
24let polling: Promise<TowerSession[]> | undefined
25// Polling starts the first time the tower is used in a session, not in every session the plugin runs in.
26let isWatching = false
27
28function folder(path: string) {
29 return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
30}
31
32async function towerDir($: $) {
33 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
34 return `${home}/.claude/tower`
35}
36
37// The running binary itself: the npm shim is a .cmd no argv call can start.
38async function claudeExe($: $) {
39 return (await $.env.get('CLAUDE_CODE_EXECPATH')) || 'claude'
40}
41
42async function readStatus($: $, dir: string, id: string): Promise<StatusFile | undefined> {
43 try {
44 return JSON.parse(String(await $.fs.read(`${dir}/sessions/${id}.json`)))
45 } catch {
46 return undefined
47 }
48}
49
50async function pollOnce($: $) {
51 try {
52 const out = await $.process.run([await claudeExe($), 'agents', '--json'], { timeoutMs: 15000 })
53 if (out.exitCode !== 0) throw new Error(out.stderr.trim() || `claude agents exited ${out.exitCode}`)
54 const agents = JSON.parse(out.stdout) as AgentRow[]
55 const dir = await towerDir($)
56 const statuses: Record<string, StatusFile> = {}
57 await Promise.all(agents.map(async a => {
58 const file = await readStatus($, dir, a.sessionId)
59 if (file) statuses[a.sessionId] = file
60 }))
61 const list = merge(agents, statuses, await $.session.id())
62 await update($, sessions, () => list)
63 await update($, failure, () => null)
64 setWaiting(list.filter(s => s.state === 'needs-input').length)
65 return list
66 } catch (err) {
67 await update($, failure, () => String(err instanceof Error ? err.message : err))
68 return read($, sessions)
69 }
70}
71
72// One poll at a time; a tick that finds one running shares its answer.
73function poll($: $) {
74 polling ??= pollOnce($).finally(() => { polling = undefined })
75 return polling
76}
77
78// A poll nobody waits for; one that outlives its module (a reload) fails quietly.
79function refresh($: $) {
80 poll($).catch(() => {})
81}
82
83function find(list: readonly TowerSession[], who: string) {
84 const w = who.toLowerCase()
85 const exact = list.find(s => s.name.toLowerCase() === w || s.id === who)
86 if (exact) return exact
87 const byLabel = list.filter(s => s.label.toLowerCase() === w)
88 if (byLabel.length) return byLabel[0]
89 return list.find(s => s.id.startsWith(who) || s.name.toLowerCase().startsWith(w))
90}
91
92async function send($: $, s: TowerSession, msg: TowerMessage) {
93 const res = await $.session.send({ to: { sessionId: s.id }, text: envelope(s.hasBeacon, msg) })
94 refresh($)
95 return res.isDelivered ? `Sent to ${s.label}.` : `Not delivered to ${s.label}: ${res.reason}`
96}
97
98// Typed text answers a pending question when the tower can; otherwise it is a new prompt.
99async function sendText($: $, s: TowerSession, text: string) {
100 if (s.pending?.kind === 'question' && s.remoteAnswers) return send($, s, { kind: 'answer', pendingId: s.pending.id, text })
101 return send($, s, { kind: 'prompt', text })
102}
103
104async function jump($: $, s: TowerSession) {
105 if (s.kind === 'background') {
106 const ran = await $.process.run(['wt.exe', '-d', s.cwd, await claudeExe($), 'attach', s.id]).catch(() => undefined)
107 return ran?.exitCode === 0 ? `Attached to ${s.label} in a new tab.` : `Couldn't open a terminal; run: claude attach ${s.id}`
108 }
109 // The window is found by process, the way the cockpit does: titles name the tab or the editor, not the session.
110 if ((await $.env.get('OS')) === 'Windows_NT' && s.pid) {
111 const script = `${$.plugin.root}/cockpit/lib/jump.ps1`
112 const ran = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script], { env: { TOWER_PID: String(s.pid) }, timeoutMs: 15000 })
113 .catch(() => undefined)
114 const app = ran?.stdout.split(' ')[0]?.trim()
115 if (ran?.exitCode === 0) return `Brought ${app || 'its window'} forward; ${s.label} is in one of its tabs.`
116 if (app) return `Windows would not bring ${app} forward; switch to it yourself. ${s.label} runs in ${s.cwd}.`
117 }
118 return `Couldn't find its window; ${s.label} runs in ${s.cwd}.`
119}
120
121// The cockpit server outlives the session: started detached, it opens the browser itself,
122// and a second start just opens the one already running.
123async function startCockpit($: $, flags: string) {
124 const server = `${$.plugin.root}/cockpit/server.mjs`
125 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
126 const argv = isWindows
127 ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', COCKPIT_PS]
128 : ['sh', '-c', 'nohup node "$TOWER_SERVER" $TOWER_FLAGS >/dev/null 2>&1 &']
129 const ran = await $.process.run(argv, { env: { TOWER_SERVER: server, TOWER_FLAGS: flags }, timeoutMs: 15000 }).catch(err => ({ exitCode: -1, stdout: '', stderr: String(err) }))
130 if (ran.exitCode !== 0) return `Couldn't start the cockpit (is Node.js 18+ installed?): ${ran.stderr.trim().slice(0, 200)}\nRun it yourself: node "${server}" ${flags}`
131 return flags.includes('--restart') ? 'Restarting the Tower cockpit and opening it in your browser.' : 'Opening the Tower cockpit in your browser. It keeps running after this session; the link it opened works until it stops.'
132}
133
134async function stopCockpit($: $) {
135 const stopped = await $.process.run(['node', `${$.plugin.root}/cockpit/server.mjs`, '--stop'], { timeoutMs: 10000 }).catch(err => ({ exitCode: -1, stdout: '', stderr: String(err) }))
136 return stopped.exitCode === 0 ? stopped.stdout.trim() : `Couldn't stop the cockpit: ${stopped.stderr.trim().slice(0, 200)}`
137}
138
139async function knownRepos($: $) {
140 const saved = ((await $.store.get('repos')) as string[] | undefined) ?? []
141 const live = (await read($, sessions)).map(s => s.cwd)
142 const seen = new Map<string, string>()
143 for (const repo of [...saved, ...live]) seen.set(repo.toLowerCase().replace(/[\\/]+$/, ''), repo)
144 return [...seen.values()].sort((a, b) => folder(a).localeCompare(folder(b)))
145}
146
147async function remember($: $, repo: string) {
148 const saved = ((await $.store.get('repos')) as string[] | undefined) ?? []
149 if (!saved.some(r => r.toLowerCase() === repo.toLowerCase())) await $.store.set('repos', [...saved, repo])
150}
151
152async function launch($: $, repo: string, task: string) {
153 const id = crypto.randomUUID()
154 const name = `${folder(repo)}-${id.slice(0, 4)}`
155 // Written first: beacon in the new session reads it at session.start.
156 await $.fs.write(`${await towerDir($)}/launched/${id}.json`, JSON.stringify({ repo, task, launchedAt: new Date(await $.clock.now()).toISOString() }))
157 const ran = await $.process.run([await claudeExe($), '--bg', '--session-id', id, '--name', name, '--', task], { cwd: repo, env: CHILD_ENV, timeoutMs: 60000 })
158 .catch(err => ({ exitCode: -1, stdout: '', stderr: String(err) }))
159 if (ran.exitCode !== 0) return `Couldn't start a session in ${repo}: ${(ran.stderr || ran.stdout).trim().slice(0, 200)}`
160 await remember($, repo)
161 refresh($)
162 return `Started ${name} in ${repo}.`
163}
164
165function rowText(s: TowerSession, now: number, width: number) {
166 const what = s.pending ? `${s.pending.title}: ${s.pending.detail ?? ''}` : (s.message ?? '')
167 const tag = s.kind === 'background' ? ' (bg)' : ''
168 const head = `${MARK[s.state]} ${(s.label + tag).padEnd(18)} ${s.state.padEnd(11)} `
169 const tail = ` ${ago(s.updatedAt, now)}`
170 const room = Math.max(0, width - head.length - tail.length - 4)
171 return head + what.replace(/\s+/g, ' ').slice(0, room).padEnd(room) + tail
172}
173
174// `launch "D:\My Repo" the task` or `launch D:\repo the task`.
175function splitPath(rest: string): [string, string] {
176 const quoted = /^"([^"]+)"\s*(.*)$/s.exec(rest)
177 if (quoted) return [quoted[1] ?? '', quoted[2] ?? '']
178 const at = rest.search(/\s/)
179 return at < 0 ? [rest, ''] : [rest.slice(0, at), rest.slice(at).trim()]
180}
181
182async function runCommand($: $, args: string): Promise<string> {
183 const [verb = '', ...words] = args.trim().split(/\s+/)
184 const rest = args.trim().slice(verb.length).trim()
185 if (verb === 'list') {
186 const list = await poll($)
187 const now = await $.clock.now()
188 return list.length ? list.map(s => rowText(s, now, 100).trimEnd()).join('\n') : 'No other sessions are running.'
189 }
190 if (verb === 'send') {
191 const [who = '', ...text] = words
192 const list = await poll($)
193 const target = find(list, who)
194 if (!target || !text.length) return `No session "${who}". Running: ${list.map(s => s.label).join(', ') || 'none'}.`
195 return sendText($, target, rest.slice(who.length).trim())
196 }
197 if (verb === 'launch') {
198 const [repo, task] = splitPath(rest)
199 if (!repo || !task) return USAGE
200 return launch($, repo, task)
201 }
202 if (verb === 'add') {
203 if (!rest) return USAGE
204 await remember($, splitPath(rest)[0])
205 return `Remembered ${rest}.`
206 }
207 return USAGE
208}
209
210// The control pane and its /tower command.
211export const registerPane: Register = (on, options) => {
212 pollMs = Number(options.pollSeconds ?? 3) * 1000
213
214 // The /tower command. Polling waits until it is first used.
215 on('session.start', { cwd: /(?:)/ }, async ($, e, next) => {
216 await $.command.register({ name: 'tower', description: 'Watch and steer every running Claude Code session', argumentHint: '[list | send <session> <prompt> | launch <repo> <task> | add <repo>]' })
217 await $.command.register({ name: 'cockpit', description: 'Open the Tower cockpit, a web dashboard over every session, in your browser', argumentHint: '[restart | stop]' })
218 return next(e)
219 })
220
221 // The cockpit server outlives this session: started detached, it opens the browser itself,
222 // and a second start just opens the one already running.
223 // /cockpit opens it; /cockpit restart replaces it (after an update); /cockpit stop stops it.
224 on('command.run', { command: 'cockpit' }, async ($, e) => {
225 const verb = e.args.trim().toLowerCase()
226 if (verb === 'stop') return { text: await stopCockpit($) }
227 return { text: await startCockpit($, verb === 'restart' ? '--restart --open' : '--open') }
228 })
229
230 on('command.run', { command: 'tower' }, async ($, e) => {
231 if (!isWatching && pollMs > 0) {
232 isWatching = true
233 $.clock.every(pollMs, () => refresh($))
234 }
235 if (e.args.trim()) return { text: await runCommand($, e.args) }
236 await update($, view, () => 'list')
237 refresh($)
238 await $.ui.open({ id: PANE, title: 'Tower', focus: true, columns: 110, rows: 24 })
239 return { text: 'Tower opened.' }
240 })
241
242 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
243 // Mobile has no text field or picker to steer with.
244 if (e.surface === 'mobile') {
245 const { Text } = $.ui.resolve(e)
246 return <Text dimColor>Open the tower in a terminal to steer sessions.</Text>
247 }
248 const { Box, Text, Button, Input, Select } = $.ui.resolve(e)
249 const width = (e.props as { bodyColumns?: number }).bodyColumns ?? e.viewport?.columns ?? 80
250 const toast = (text: string) => $.ui.toast(text)
251
252 if ((await read($, view)) === 'launch') {
253 const repos = await knownRepos($)
254 const repo = (await read($, launchRepo)) ?? repos[0] ?? null
255 return (
256 <Box flexDirection="column">
257 <Text bold>New session</Text>
258 {repos.length === 0
259 ? <Text dimColor>No repos known yet. Add one with /tower add <path>.</Text>
260 : <Select key="repo" label="Repo" value={repo ?? undefined} options={repos.map(r => ({ value: r, label: `${folder(r)} ${r}` }))}
261 onSelect={value => void update($, launchRepo, () => value)} />}
262 {repo && <Input key="task" label={`Task for ${folder(repo)}`} placeholder="What should it do?" autoFocus submitLabel="launch"
263 onSubmit={task => {
264 if (!task.trim()) return
265 void launch($, repo, task.trim()).then(toast)
266 void update($, view, () => 'list')
267 }} />}
268 <Box gap={1}>
269 <Button key="back" hotkey="b" onPress={() => void update($, view, () => 'list')}>Back</Button>
270 </Box>
271 </Box>
272 )
273 }
274
275 const list = await read($, sessions)
276 const error = await read($, failure)
277 const now = await $.clock.now()
278 const chosen = await read($, selected)
279 const target = list.find(s => s.id === chosen) ?? list[0]
280 const needs = list.filter(s => s.state === 'needs-input')
281 const focused = (e.props as { isFocused?: boolean }).isFocused === true
282 // Room for the session's last words: what the rows and the box leave.
283 const previewLines = Math.min(4, Math.max(1, (e.viewport?.rows ?? 30) - list.length - needs.length * 2 - 16))
284 const cols = columns(width)
285 // Docked, the pane is at least as tall as its room, so the send box sits at its bottom like a chat's.
286 const props = e.props as { placement?: string; scroll?: { bodyRows?: number } }
287 const room = props.placement === 'dock' ? Math.max(0, (props.scroll?.bodyRows ?? 0) - 1) : 0
288
289 // Two sessions in one folder share a label; the first letters of the id tell them apart.
290 const labelWidth = width >= 120 ? 30 : cols.labelWidth
291 const nameOf = (s: TowerSession, room = 200) => {
292 const tag = (list.filter(o => o.label === s.label).length > 1 ? ` #${s.id.slice(0, 4)}` : '') + (s.kind === 'background' ? ' (bg)' : '')
293 const label = s.label.length + tag.length > room ? s.label.slice(0, Math.max(4, room - tag.length - 1)) + '…' : s.label
294 return label + tag
295 }
296 const pick = (s: TowerSession) => void update($, selected, () => s.id)
297 const move = (step: number) => {
298 if (!list.length) return
299 const at = Math.max(0, list.findIndex(s => s.id === target?.id))
300 pick(list[(at + step + list.length) % list.length] as TowerSession)
301 }
302
303 // One card per session that waits on you. Only the first takes the keys: two cards on one key would clash.
304 const card = (s: TowerSession, first: boolean) => {
305 const p = s.pending
306 const answerable = s.remoteAnswers && p
307 return (
308 <Box key={`card-${s.id}`} gap={1} flexWrap="wrap">
309 <Text color="warning" bold>{p?.kind === 'question' ? '?' : '!'}</Text>
310 <Button key={`pick-card-${s.id}`} plain onPress={() => pick(s)}>{nameOf(s)}</Button>
311 <Text dimColor>{!p ? 'needs you' : p.kind === 'question' ? 'asks' : answerable ? 'needs approval' : 'waits in its terminal'}</Text>
312 <Box flexGrow={1} flexShrink={1}><Text wrap="truncate-end">{p ? `${p.title} ${p.detail ?? ''}`.trim() : (s.message ?? '')}</Text></Box>
313 {answerable && p.kind === 'permission' && (
314 <Box gap={1}>
315 <Button key={first ? 'allow' : `allow-${s.id}`} hotkey={first ? 'a' : undefined} variant="primary" onPress={() => void send($, s, { kind: 'answer', pendingId: p.id, allow: true }).then(toast)}>{first ? 'Allow (a)' : 'Allow'}</Button>
316 <Button key={first ? 'deny' : `deny-${s.id}`} hotkey={first ? 'd' : undefined} onPress={() => void send($, s, { kind: 'answer', pendingId: p.id, allow: false }).then(toast)}>{first ? 'Deny (d)' : 'Deny'}</Button>
317 </Box>
318 )}
319 {answerable && p.kind === 'question' && (
320 <Box gap={1} flexWrap="wrap">
321 {(p.options ?? []).slice(0, 4).map((label, i) => (
322 <Button key={`opt-${s.id}-${i}`} hotkey={first ? String(i + 1) : undefined} onPress={() => void send($, s, { kind: 'answer', pendingId: p.id, text: label }).then(toast)}>{first ? `${i + 1} ${label}` : label}</Button>
323 ))}
324 </Box>
325 )}
326 {!answerable && <Button key={`jump-card-${s.id}`} onPress={() => void jump($, s).then(toast)}>Jump</Button>}
327 </Box>
328 )
329 }
330
331 // One line per session: what it is doing, without picking it first.
332 const row = (s: TowerSession) => {
333 const isChosen = s.id === target?.id
334 const what = s.pending ? `${s.pending.title}: ${s.pending.detail ?? ''}` : (s.message ?? s.state)
335 return (
336 <Box key={`row-${s.id}`} gap={1}>
337 <Text color={isChosen ? 'suggestion' : undefined} bold>{isChosen ? '▶' : ' '}</Text>
338 <Text color={COLOR[s.state]} dimColor={s.state === 'idle'}>{MARK[s.state]}</Text>
339 <Button key={`pick-${s.id}`} plain onPress={() => pick(s)}>{nameOf(s, labelWidth).padEnd(labelWidth)}</Button>
340 <Box flexGrow={1} flexShrink={1}>{cols.showState && <Text dimColor={!isChosen} wrap="truncate-end">{what.replace(/\s+/g, ' ')}</Text>}</Box>
341 <Box flexShrink={0}><Text dimColor>{ago(s.updatedAt, now)}</Text></Box>
342 </Box>
343 )
344 }
345
346 const answer = target?.lastAnswer ? tail(target.lastAnswer, previewLines).split('\n') : []
347 const title = target ? ` ${nameOf(target)} · ${target.state} · ${target.cwd} ` : ''
348
349 return (
350 <Box flexDirection="column" gap={1} minHeight={room || undefined}>
351 <Box gap={1} flexWrap="wrap">
352 <Text bold>Tower</Text>
353 <Text dimColor>· {list.length} session{list.length === 1 ? '' : 's'}</Text>
354 {needs.length > 0 && <Text color="warning" bold>· {needs.length} need{needs.length === 1 ? 's' : ''} you</Text>}
355 <Box flexGrow={1} />
356 {focused
357 ? <Text color="success">● Typing here · Esc back to your prompt</Text>
358 : <Text dimColor>Click here or press ctrl+x Tab to use it</Text>}
359 </Box>
360 {error && <Text color="warning">Stale: {error}</Text>}
361
362 {needs.length > 0 && (
363 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
364 <Text color="warning" bold>Needs input</Text>
365 {needs.map((s, i) => card(s, i === 0))}
366 </Box>
367 )}
368
369 <Box flexDirection="column" gap={1}>
370 {list.length === 0 && <Text dimColor>No other sessions are running. Press n to start one.</Text>}
371 {[['Working', list.filter(s => s.state === 'working')], ['Completed', list.filter(s => s.state === 'done' || s.state === 'idle')]].map(([title, members]) => (members as TowerSession[]).length > 0 && (
372 <Box key={`group-${title}`} flexDirection="column">
373 <Text dimColor bold>{title as string}</Text>
374 {(members as TowerSession[]).map(row)}
375 </Box>
376 ))}
377 </Box>
378
379 {target && (
380 <Box flexDirection="column">
381 <Text dimColor wrap="truncate-middle">{'──' + title + '─'.repeat(Math.max(2, width - title.length - 4))}</Text>
382 {!target.hasBeacon && <Text dimColor>No preview: this session runs without Tower.</Text>}
383 {target.lastPrompt && (
384 <Box gap={1}>
385 <Text bold>{'You '}</Text>
386 <Box flexGrow={1} flexShrink={1}><Text dimColor wrap="truncate-end">{target.lastPrompt.replace(/\s+/g, ' ')}</Text></Box>
387 </Box>
388 )}
389 {answer.length > 0 && (
390 <Box gap={1}>
391 <Text bold color="suggestion">Claude</Text>
392 <Box flexGrow={1} flexShrink={1} flexDirection="column">
393 {answer.map((line, i) => <Text key={`line-${i}`} wrap="truncate-end">{line}</Text>)}
394 </Box>
395 </Box>
396 )}
397 {!target.lastPrompt && answer.length === 0 && target.hasBeacon && <Text dimColor>Nothing said yet.</Text>}
398 </Box>
399 )}
400
401 <Box flexGrow={1} />
402
403 {target && (
404 <Box borderStyle="round" borderColor={focused ? 'suggestion' : undefined} borderDimColor={!focused} paddingX={1}>
405 <Input key={`say-${target.id}`} label={`To ${nameOf(target)}`}
406 placeholder={target.pending?.kind === 'question' && target.remoteAnswers ? 'Type an answer, Enter sends' : 'Type a message or /command, Enter sends'} submitLabel="send"
407 onSubmit={text => { if (text.trim()) void sendText($, target, text.trim()).then(toast) }} />
408 </Box>
409 )}
410
411 <Box gap={1} flexWrap="wrap">
412 <Button key="prev" hotkey="k" onPress={() => move(-1)}>↑ Prev (k)</Button>
413 <Button key="next" hotkey="j" onPress={() => move(1)}>↓ Next (j)</Button>
414 {target && <Button key="jump" hotkey="g" onPress={() => void jump($, target).then(toast)}>Jump (g)</Button>}
415 <Button key="new" hotkey="n" onPress={() => void update($, view, () => 'launch')}>New session (n)</Button>
416 <Button key="cockpit" hotkey="o" onPress={() => void startCockpit($, '--open').then(toast)}>Open cockpit (o)</Button>
417 <Button key="refresh" hotkey="r" onPress={() => refresh($)}>Refresh (r)</Button>
418 </Box>
419 </Box>
420 )
421 })
422}
423hooks/strip.ts 112 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { compose } from './line'
4import type { StripParts } from './line'
5
6type $ = EngineInterface
7
8let showBranch = true
9let showContext = true
10let showCost = true
11let showLine = true
12let pollMs = 5000
13// What git, the model and the usage said last; mode and effort as the engine last reported them.
14let known: StripParts = { folder: '' }
15let waiting = 0
16let shown: string | undefined
17
18function folder(path: string) {
19 return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
20}
21
22async function git($: $, root: string, args: string[]) {
23 const ran = await $.process.run(['git', '-C', root, ...args], { timeoutMs: 3000 }).catch(() => undefined)
24 return ran?.exitCode === 0 ? ran.stdout : undefined
25}
26
27// Draws from what is known; no process runs, so it is cheap enough for every tool call.
28// With the line switched off, only the tower's count is left to show.
29function draw($: $) {
30 const line = showLine ? compose({ ...known, waiting }) : (compose({ folder: '', waiting }) || undefined)
31 if (line !== shown) $.ui.status((shown = line))
32}
33
34// Redraws with the session's own `$`, kept from session.start: `$` never crosses a file.
35let redraw: (() => void) | undefined
36
37// The pane's poll reports how many other sessions wait on you; one status entry carries both.
38export function setWaiting(count: number) {
39 waiting = count
40 redraw?.()
41}
42
43// Re-reads what changes on its own: the branch, the model, the context and the cost.
44async function refresh($: $) {
45 const repo = await $.session.repo().catch(() => null)
46 const root = repo?.root ?? (await $.session.cwd())
47 const next: StripParts = { ...known, folder: repo?.name ?? folder(root), branch: undefined, isDirty: undefined }
48 if (showBranch && repo) {
49 const [branch, changes] = await Promise.all([git($, root, ['rev-parse', '--abbrev-ref', 'HEAD']), git($, root, ['status', '--porcelain'])])
50 next.branch = branch?.trim() || undefined
51 next.isDirty = Boolean(changes?.trim())
52 }
53 next.model = await $.session.model().catch(() => known.model)
54 const usage = await $.session.usage().catch(() => undefined)
55 next.contextPercent = showContext ? usage?.context.percent : undefined
56 next.costUsd = showCost ? usage?.cost?.usd : undefined
57 known = next
58 draw($)
59}
60
61function background($: $) {
62 refresh($).catch(() => {})
63}
64
65// The classic hook fields that say how the session runs right now.
66// How the session runs, as last reported: beacon copies it into the status file.
67export function currentRun(): { mode: string | null; effort: string | null } {
68 return { mode: known.mode ?? null, effort: known.effort ?? null }
69}
70
71function heard($: $, e: { permission_mode?: string; effort?: { level: string } }) {
72 if (e.permission_mode) known = { ...known, mode: e.permission_mode }
73 if (e.effort?.level) known = { ...known, effort: e.effort.level }
74 draw($)
75}
76
77// The status line.
78export const registerStrip: Register = (on, options) => {
79 showBranch = options.showBranch !== false
80 showContext = options.showContext !== false
81 showCost = options.showCost !== false
82 showLine = options.statusLine !== false
83 pollMs = Number(options.statusSeconds ?? 5) * 1000
84
85 // The first line, then a refresh on a timer.
86 on('session.start', { cwd: /^/ }, async ($, e, next) => {
87 redraw = () => draw($)
88 if (!showLine) return next(e)
89 const effort = await $.env.get('CLAUDE_EFFORT')
90 if (effort && !known.effort) known = { ...known, effort }
91 await refresh($).catch(() => {})
92 if (pollMs > 0) $.clock.every(pollMs, () => background($))
93 return next(e)
94 })
95
96 on('classic.UserPromptSubmit', async ($, e, next) => {
97 heard($, e)
98 return next(e)
99 }).catch(($, e, next) => next(e))
100
101 on('classic.PostToolUse', async ($, e, next) => {
102 heard($, e)
103 return next(e)
104 }).catch(($, e, next) => next(e))
105
106 on('classic.Stop', async ($, e, next) => {
107 heard($, e)
108 background($)
109 return next(e)
110 }).catch(($, e, next) => next(e))
111}
112hooks/model.ts 98 lines1import type { TowerPending, TowerSession, TowerState } from '../types'
2
3// One row of `claude agents --json`.
4export type AgentRow = {
5 sessionId: string
6 pid?: number
7 cwd: string
8 kind?: string
9 startedAt?: number
10 name?: string
11 status?: string
12}
13
14// beacon's status file, as far as the tower reads it.
15export type StatusFile = {
16 sessionId?: string
17 label?: string
18 state?: string
19 message?: string | null
20 pending?: TowerPending | null
21 remoteAnswers?: boolean
22 lastPrompt?: string | null
23 lastAnswer?: string | null
24 updatedAt?: string
25}
26
27export type TowerMessage =
28 | { kind: 'prompt'; text: string }
29 | { kind: 'answer'; pendingId: string; allow?: boolean; text?: string }
30
31const RANK: Record<TowerState, number> = { 'needs-input': 0, working: 1, done: 2, idle: 3 }
32const BEACON_STATES = new Set(['needs-input', 'working', 'done', 'idle'])
33
34function folder(path: string) {
35 return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
36}
37
38// The engine's live list is who exists; beacon's file, when there is one, says what each is doing.
39export function merge(agents: readonly AgentRow[], statuses: Readonly<Record<string, StatusFile>>, selfId: string): TowerSession[] {
40 const list = agents.filter(a => a.sessionId !== selfId).map(a => {
41 const file = statuses[a.sessionId]
42 const beacon = file && BEACON_STATES.has(file.state ?? '') ? file : undefined
43 const isBusy = a.status === 'busy'
44 let state: TowerState = isBusy ? 'working' : 'idle'
45 if (beacon) state = beacon.state === 'working' && !isBusy ? 'idle' : (beacon.state as TowerState)
46 const updated = beacon?.updatedAt ? Date.parse(beacon.updatedAt) : NaN
47 return {
48 id: a.sessionId,
49 name: a.name ?? folder(a.cwd),
50 label: beacon?.label ?? folder(a.cwd),
51 cwd: a.cwd,
52 pid: a.pid ?? null,
53 kind: a.kind === 'background' ? 'background' : 'interactive',
54 state,
55 message: beacon?.message ?? null,
56 pending: beacon?.pending ?? null,
57 hasBeacon: Boolean(beacon),
58 remoteAnswers: beacon?.remoteAnswers === true,
59 lastPrompt: beacon?.lastPrompt ?? null,
60 lastAnswer: beacon?.lastAnswer ?? null,
61 updatedAt: Number.isNaN(updated) ? (a.startedAt ?? 0) : updated,
62 } satisfies TowerSession
63 })
64 return list.sort((x, y) => RANK[x.state] - RANK[y.state] || y.updatedAt - x.updatedAt)
65}
66
67// What the tower sends: beacon reads the JSON; a session without it reads the words.
68export function envelope(hasBeacon: boolean, msg: TowerMessage) {
69 if (!hasBeacon) return msg.kind === 'prompt' ? msg.text : (msg.text ?? (msg.allow ? 'Approved.' : 'Denied.'))
70 return `[tower] ${JSON.stringify({ v: 1, ...msg })}`
71}
72
73export function ago(then: number, now: number) {
74 const s = Math.max(0, Math.round((now - then) / 1000))
75 if (s < 60) return `${s}s`
76 if (s < 3600) return `${Math.round(s / 60)}m`
77 if (s < 86400) return `${Math.round(s / 3600)}h`
78 return `${Math.round(s / 86400)}d`
79}
80
81export const MARK: Record<TowerState, string> = { 'needs-input': '!', working: '~', done: '✓', idle: '·' }
82
83// Theme colors, so light and dark themes both read; idle is drawn dim instead.
84export const COLOR: Record<TowerState, string | undefined> = { 'needs-input': 'warning', working: 'suggestion', done: 'success', idle: undefined }
85
86// Which columns a session row has room for at this pane width.
87export function columns(width: number) {
88 return {
89 showState: width >= 60,
90 showMessage: width >= 90,
91 labelWidth: width >= 60 ? 18 : Math.max(8, width - 14),
92 }
93}
94
95export function tail(text: string, lines: number) {
96 return text.split('\n').filter(line => line.trim()).slice(-lines).join('\n')
97}
98hooks/line.ts 47 lines1// What the status line knows; any part left out is not drawn.
2export type StripParts = {
3 folder: string
4 branch?: string
5 isDirty?: boolean
6 model?: string
7 effort?: string
8 mode?: string
9 contextPercent?: number
10 costUsd?: number
11 // Other sessions waiting on you, from the tower's poll; leads the line when any are.
12 waiting?: number
13}
14
15const MODES: Record<string, string> = {
16 default: 'default',
17 acceptEdits: 'accept edits',
18 plan: 'plan',
19 auto: 'auto',
20 bypassPermissions: 'bypass',
21 dontAsk: "don't ask",
22}
23
24// `claude-opus-5-5` reads `Opus 5.5`; a name already readable is kept.
25// Finds the name wherever it sits in the id, so a provider's prefix
26// ("eu.anthropic.claude-sonnet-5-5") needs no special case.
27export function modelName(model: string) {
28 const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(model)
29 if (!m) return model
30 const family = (m[1] ?? '').charAt(0).toUpperCase() + (m[1] ?? '').slice(1)
31 return `${family} ${m[2]}${m[3] ? '.' + m[3] : ''}${/\[1m\]/i.test(model) ? ' 1M' : ''}`
32}
33
34export function compose(p: StripParts) {
35 const where = p.branch ? `${p.folder} ⎇ ${p.branch}${p.isDirty ? '*' : ''}` : p.folder
36 const mode = p.mode === undefined ? undefined : (MODES[p.mode] ?? p.mode)
37 return [
38 p.waiting ? `${p.waiting} need${p.waiting === 1 ? 's' : ''} you` : undefined,
39 where,
40 p.model && modelName(p.model),
41 p.effort,
42 mode,
43 p.contextPercent === undefined ? undefined : `ctx ${Math.round(p.contextPercent)}%`,
44 p.costUsd === undefined ? undefined : `$${p.costUsd.toFixed(2)}`,
45 ].filter(Boolean).join(' · ')
46}
47types/index.d.ts 56 lines1export type TowerState = 'needs-input' | 'working' | 'done' | 'idle'
2
3// What beacon reports a session waits on (its status file's `pending`).
4export type TowerPending = {
5 id: string
6 kind: 'permission' | 'question'
7 title: string
8 detail?: string
9 options?: string[]
10 // The agent's last words before it asked, and for a file edit the change it wants.
11 why?: string
12 diff?: [string, 'hunk' | 'ctx' | 'del' | 'add'][]
13 since?: string
14}
15
16// What this session waits on that the tower can answer; `key` is the permission's
17// exact call, so an approval allows that call and no other.
18export type BeaconPending = TowerPending & { key?: string }
19
20// One running session as the pane draws it: the engine's live list, enriched by beacon.
21export type TowerSession = {
22 id: string
23 name: string
24 label: string
25 cwd: string
26 // The session's process, from which its window is found.
27 pid: number | null
28 kind: 'interactive' | 'background'
29 state: TowerState
30 message: string | null
31 pending: TowerPending | null
32 hasBeacon: boolean
33 remoteAnswers: boolean
34 // The conversation preview beacon keeps: the last prompt, and how the last answer ended.
35 lastPrompt: string | null
36 lastAnswer: string | null
37 updatedAt: number
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 tower: {
43 // The pane's view of every other session.
44 sessions: TowerSession[]
45 selected: string | null
46 view: 'list' | 'launch'
47 launchRepo: string | null
48 // When the last poll failed, why; the list then is the last good one.
49 error: string | null
50 // This session's own wait for the tower, and one-shot approvals from it.
51 pending: BeaconPending | null
52 approvals: string[]
53 }
54 }
55}
56