SLOPSHOPPER

tower

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…

newpaneguardcommandtoaststatus
★ 1v0.4.3Apache-2.0updated 2026-10-07alaturqua/tower-mods/plugins/tower
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tower
│ ┃ Tower ✕ › fix the failing auth test and add an audit log call │ ┃ Tower · 0 sessions Click here or press ctrl │ ┃ ⏺ Read(src/auth.ts) │ ┃ Stale: JSON Parse error: Unexpected EOF ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No other sessions are running. Press n to ⎿ Added 2 lines, removed 1 line │ ┃ start one. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ ↑ Prev (k) ] [ ↓ Next (j) ] [ New session ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /tower │ ┃ ⎿ tower: Tower opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ tower: app · Opus 5.5 · ctx 49% · $0.42

Draws

Pane · Tower
Tower · 0 sessions Click here or press ctrl+x Tab to use it Stale: JSON Parse error: Unexpected EOF No other sessions are running. Press n to start one. [ ↑ Prev (k) ] [ ↓ Next (j) ] [ New session (n) ] [ Open coc
README

Tower

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">

Install

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.

The cockpit

AreaWhat it does
Needs youPermission 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.
FleetEvery 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 workstreamsThe 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 agentIts 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.

How it works and what it trusts

  • Local only. The cockpit is a small Node server inside the plugin (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.
  • What it reads: 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.
  • How it steers: prompts, slash commands and answers are appended to ~/.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.
  • Answering from the cockpit: a permission dialog belongs to its terminal. So by default only agents the cockpit launched send their permission prompts and questions to the cockpit. The agent is told to wait, your Allow arrives as its next prompt, and that exact call runs once. For a session you started by hand, the cockpit shows what it waits on and Jump brings its window forward. Set remoteAnswers to always to answer every session from the cockpit.
  • Launching agents: Claude Code must have been trusted in a repository once (open Claude Code there and accept the prompt) before the cockpit can start agents in it.

In the terminal

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>.
  • Notifications: a desktop notification when a session needs you (Windows toast, macOS osascript, Linux notify-send).
  • Status line: 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.

Settings

Change these in /config or under pluginConfigs.tower.options in ~/.claude/settings.json:

OptionDefaultMeaning
notificationstrueDesktop notifications at all
notifyOnDonetrueNotify when a turn ends after minTurnSeconds
minTurnSeconds30Shorter turns end quietly
notifyOnIdlefalseAlso notify on Claude Code's "still waiting" reminders
remoteAnswerslaunchedWhich sessions send permission prompts and questions to the cockpit and tower: launched, always, never
inboxSeconds1How often a session checks for prompts and answers from the cockpit; 0 turns it off
pollSeconds3How often /tower refreshes, once it has run in a session
statusLinetrueThe status line at all
showBranch, showContext, showCosttrueParts of the status line
statusSeconds5How 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.

Developing

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.

Releasing

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.

License

Apache-2.0, see LICENSE.

Source 7 files
hooks/register.ts 14 lines
1import 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}
14
hooks/beacon.ts 420 lines
1import { 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}
420
hooks/pane.tsx 423 lines
1import { 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 &lt;path&gt;.</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}
423
hooks/strip.ts 112 lines
1import 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}
112
hooks/model.ts 98 lines
1import 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}
98
hooks/line.ts 47 lines
1// 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}
47
types/index.d.ts 56 lines
1export 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