SLOPSHOPPER

sidebar-bridge

Forwards Claude Code session events to the claude-terminal sidebar.

guardpromptprocesstimeragents
A shopper browsing a rack in a slop shop
README

claude-terminal

A native macOS app for running Claude Code sessions. Each window is a terminal running Claude Code plus a live sidebar that shows what Claude is doing, what it costs, and what files it's touching. Markdown and image files Claude reads or writes open inline as tabs.

Built on Ghostty's terminal engine.

claude-terminal screenshot

Install

  1. Download claude-terminal-X.Y.Z.dmg from the latest release.
  2. Open the DMG and drag claude-terminal to /Applications.
  3. Launch it. Updates arrive automatically via Sparkle.

Requires macOS 14 and the Claude Code CLI 2.1.301 or newer. The sidebar gets its data from a Claude Code mod that the app loads into each session it starts, so it changes nothing in ~/.claude/settings.json. To update Claude Code:

claude update

Your first session

When you launch claude-terminal you'll see a directory picker. Pick the project folder you want to work in — that becomes the working directory for Claude Code. A new window opens with:

  • The terminal on the left, running claude in your project.
  • The sidebar on the right, showing session status, cost, context usage, and anything Claude is currently doing.

Type a prompt in the terminal as you would with claude directly. As Claude responds, the sidebar updates in real time.

Every window is its own Claude session with its own working directory. Open a second project by pressing Cmd+N.

Reading the sidebar

The sidebar is a set of cards. They appear and collapse as they become relevant, so it's not all visible at once.

Status headline. The top of the sidebar shows what Claude is doing right now — thinking, running a tool, waiting for input. When a turn ends, the label flips to Done for ~10 seconds before fading to plain Idle, so a glance up from the terminal tells you whether Claude just stopped or has been quiet for a while. If Claude finishes without saying anything (e.g. reads a screenshot and returns end_turn with no text), the label reads Done · no reply — making the silent stop obvious instead of leaving you guessing. The current tool name and a short argument summary appear beneath it, tinted by tool type. The most recent italic line of assistant prose surfaces here too, with inline markdown rendered (bold, *italic*, ` code `) instead of raw asterisks.

Activity chart. A CPU-style bidirectional bar graph of the last 3 minutes. Claude bars grow up from the centerline, tool bars grow down. Colour ramps light-blue → yellow → orange → red as a single operation stays busy, peaking at red after 30 minutes — useful for spotting a stuck tool call. Hover a tool bar to see which tools ran in that slice.

Context & cost. Cumulative cost, and session and API time. The Context card is described below. Cost and context always stay visible — even when the window is short and other cards collapse to single-line summaries.

Subagents. When Claude spawns a subagent (Explore, Plan, etc.), it appears as a row with a coloured status dot, the agent name, the tool it's running, and the argument detail. Right-click a row for Force Quit.

Tasks. The task list from TaskCreate, with dependency ordering. Blocked tasks are marked so you can see why a task isn't progressing.

Documents. Any .md file or image (PNG, JPG, GIF, SVG, WebP, HEIC, AVIF, …) Claude reads, writes, or edits shows up as a card. Markdown cards show filename + first heading + excerpt; image cards show a live thumbnail.

Working directory. Your cwd with a project favicon auto-loaded from the folder. A GitHub icon appears next to it when cwd is inside a git repo and GitHub's public status API reports an incident — hover for the summary, click to open status.github.com.

Context breakdown. The Context card shows how full the context window is. Open Details to see what fills it, as /context does: system prompt, tools, MCP tools, skills, memory files, messages and free space, with the MCP tools and memory files by size. The circular arrow (Refresh) counts it again, at about one API request per tool. The inward arrows (Compact session) summarize the conversation, as /compact does. Hold the pointer over either button to see its name.

Model and effort. Click the model name under the session name to switch this session's model or effort level, as /model and /effort do. Keys 1 to 4 pick Opus, Fable, Sonnet and Haiku. More models has the 1M-context variants, Opus Plan and the default from your settings. A switch made during a turn applies when the turn ends.

Pull request. Needs the GitHub CLI (gh), signed in. When the current branch has an open PR, a card surfaces a GitHub-style state pill (green Open, gray Draft) and the PR number as a clickable link to GitHub. A right-aligned chip shows the review decision (approved, changes requested, review pending) when one is set. The data comes from Claude Code's statusline payload — no gh auth needed on the claude-terminal side.

Jira ticket. If your branch name or a recent prompt mentions a Jira key (e.g. ABC-123), the ticket number and title surface here as a clickable link. Requires a Jira CLI (ankitpokhrel/jira-cli or go-jira) on PATH.

Viewing files Claude touches

The Documents section lets you read the same markdown and images Claude is working on, without leaving the app.

  • Click a card — or drag any .md or image onto the sidebar — to open it in a tab that covers the terminal area. The sidebar stays in place.
  • Open from Finder: right-click a .md or image → Open With → claude-terminal.

Markdown tabs render full GitHub-flavored markdown — tables, task lists, fenced code with syntax highlighting, and mermaid diagrams rendered inline. Each mermaid diagram supports ⌘-scroll zoom, drag-pan, and double-click to reset.

Image tabs support ⌘-scroll zoom and drag-to-pan. They auto-reload on save, so an image regenerated by convert, ffmpeg, or Photoshop updates live without you touching anything.

Cmd+F searches the open document. Cmd+= / Cmd+- / Cmd+0 zoom in, out, reset. Scroll position is preserved when you switch tabs or quit and relaunch.

Running multiple sessions

  • Cmd+N opens a new session. Pick a different project folder for each one.
  • Switch between open windows with the Window menu or Cmd+1 … Cmd+9.
  • The menu-bar icon pops up a grid of all sessions (active and recently used). Click any tile to focus or reopen. You can set the trigger to hover or click, and hide the icon entirely, in Settings → Menu Bar.
  • Cmd+Shift+R renames the current session — helpful when ~/projects/foo isn't descriptive enough.
  • Right-click the terminal → Set Terminal Background Color… to tint a session for visual distinction at a glance. The tint is per-window, sticks across /clear, and also colors the session's tile in the menu-bar popover. Pick Remove Custom Background to revert.

Saving and resuming work

Use Save & Quit (from the claude-terminal menu) instead of closing windows individually. On next launch, every window reopens with --continue against the same Claude Code session, including any document tabs you had open and their scroll positions.

Window size, position, sidebar width, and renamed session names also persist.

From the command line

On first launch the app offers to install a symlink at ~/.local/bin/claude-terminal. After that:

claude-terminal                              # directory picker
claude-terminal ~/projects/myapp             # open a specific directory
claude-terminal --name "feature work" .      # named session
claude-terminal ~/myapp -- --resume          # pass args through to claude

If the app is already running, the CLI opens a new window in the same process rather than launching a second copy.

Notifications

macOS notifications fire when Claude waits on you for a permission request. They only fire when the app is in the background, so you won't get double-pinged while you're already looking at it. Toggle them in Settings → Notifications.

Keyboard shortcuts

ActionShortcut
New sessionCmd+N
Open sessionCmd+O
Close windowCmd+W
Toggle sidebarCmd+B
Rename sessionCmd+Shift+R
Switch to window 1–9Cmd+1 … Cmd+9
Find in terminal / open markdown docCmd+F
Find next / previousCmd+G / Cmd+Shift+G
Zoom markdown in / out / resetCmd+= / Cmd+- / Cmd+0

Uninstall

claude-terminal menu → Uninstall Claude Terminal… removes the app from /Applications, deletes ~/.claude-terminal/, removes the CLI symlink, and clears app preferences.

Dragging the app to Trash is also safe. The app changes nothing outside ~/.claude-terminal/ and its preferences.

Versions before 0.43 added hook and statusLine entries to ~/.claude/settings.json. Launching 0.43 or newer once removes them. If you removed an old version without that, run scripts/uninstall.sh from this repository.

Contributing

Source layout, build instructions, release process, and architecture notes live under docs/ and CONTRIBUTING.md.

License

MIT

Source 2 files
hooks/register.ts 520 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3import type {
4  SidebarBridgeApiMs,
5  SidebarBridgeBreakdown,
6  SidebarBridgeContextRow,
7  SidebarBridgeEffort,
8  SidebarBridgeStarted,
9  SidebarBridgeStartedAt,
10} from '../types'
11
12// Loaded in each session claude-terminal launches, via CLAUDE_CODE_PLUGIN_DIRS.
13// It is the app's whole Claude Code integration: nothing is written to
14// ~/.claude/settings.json. Each native event becomes the stdin JSON the
15// claude-terminal-hook binary reads, under the classic hook name the binary
16// already handles, so status.json and the /clear cost baseline keep one writer.
17//
18// No classic.* events: the built-in cc-plugin-sec-default skips user-tier hooks
19// on them wherever managed settings exist.
20
21// The settings hooks ran with `"timeout": 5`.
22const TIMEOUT_MS = 5000
23
24// The statusLine ran every 3 s. session.measure only fires when context, cost
25// or a rate limit moves, so model and repo changes come from this poll.
26const STATUS_POLL_MS = 3000
27
28// Held by the host for the session, so a hot reload keeps it.
29const started = atom({ plugin: 'sidebar-bridge', key: 'started' } as const, false as SidebarBridgeStarted)
30
31let helper: string | undefined
32
33// Every helper run takes the same flock on .status.lock, so they finish one at
34// a time however many are started. Runs started faster than they finish (the
35// 3 s poll, PR lookups, the breakdown, a burst of tool calls) used to queue on
36// that lock, and the one at the back passed TIMEOUT_MS and was killed. Each
37// send now waits for the one before it, so a run's TIMEOUT_MS counts its own
38// work and not the queue's.
39let lastSend: Promise<void> = Promise.resolve()
40
41// `repeats`: the next status poll sends the same figures within STATUS_POLL_MS,
42// so a run that fails is dropped with no message.
43type SendOptions = { repeats?: boolean }
44
45function send($: EngineInterface, subcommand: 'hook' | 'statusline', payload: Record<string, unknown>, options: SendOptions = {}) {
46  const run = lastSend.then(() => runHelper($, subcommand, payload, options))
47  lastSend = run
48  return run
49}
50
51async function runHelper(
52  $: EngineInterface,
53  subcommand: 'hook' | 'statusline',
54  payload: Record<string, unknown>,
55  options: SendOptions,
56) {
57  helper ??= await $.env.get('CLAUDE_TERMINAL_HOOK')
58  if (!helper) return
59  const stdin = JSON.stringify({
60    session_id: await $.session.id(),
61    cwd: await $.session.cwd(),
62    ...payload,
63  })
64  // cwd "/": the helper reads the session's directory from stdin, never its
65  // own. Run in the session's directory, a spawn fails once that directory is
66  // gone (a removed git worktree, say), and posix_spawn reports it as ENOENT on
67  // the helper's path.
68  const attempt = () => $.process.run([helper!, subcommand], { stdin, cwd: '/', timeoutMs: TIMEOUT_MS })
69  try {
70    await attempt()
71  } catch (err) {
72    // A run throws when it passes TIMEOUT_MS or cannot start; a helper that
73    // fails exits non-zero and does not throw. The timeouts come almost all
74    // from sleep: a dark wake starts a run, the Mac sleeps again before it
75    // ends, and the timer counts the sleep. The helper takes about 30 ms.
76    if (options.repeats) return
77    // Nothing sends this one again, so it gets one more try: by then the Mac
78    // is awake. A run the timeout cut after its write does no harm twice.
79    try {
80      await attempt()
81      return
82    } catch (retryErr) {
83      err = retryErr
84    }
85    $.ui.log(`sidebar-bridge: ${subcommand} failed: ${String(err)}`)
86  }
87}
88
89const hook = ($: EngineInterface, payload: Record<string, unknown>) => send($, 'hook', payload)
90
91// How long an "ask" waits before it counts as a dialog the person must answer.
92// tool.check says "ask" before the mode settles it, and auto mode's classifier
93// then approves or refuses in about 1.5 s with no dialog. A call still pending
94// past this is in front of the person. No native event reports the dialog
95// itself, and no settings file reliably names the mode.
96const ASK_SETTLE_MS = 4000
97
98// Summed model time of the main loop's turns, the statusLine's
99// total_api_duration_ms. Held by the host so a hot reload keeps it; turn.complete
100// adds to it.
101const apiMs = atom({ plugin: 'sidebar-bridge', key: 'apiMs' } as const, 0 as SidebarBridgeApiMs)
102
103// When this process's session began. usage.startedAt is a resumed session's
104// first launch, which made `--continue` report days of duration; the
105// statusLine's total_duration_ms counted from this process's start.
106const startedAt = atom({ plugin: 'sidebar-bridge', key: 'startedAt' } as const, 0 as SidebarBridgeStartedAt)
107
108// The /context breakdown. A full one asks the API to count each tool and
109// memory file, about one request per tool, so it runs at session start, after
110// a compact and when the person presses Refresh. Between those the fixed rows
111// hold, and each turn rebuilds Messages from the main context's input tokens.
112const breakdown = atom({ plugin: 'sidebar-bridge', key: 'breakdown' } as const, null as SidebarBridgeBreakdown)
113
114// The main loop's effort level. No call reads it: each request's turn.step
115// carries it, and an /effort this mod runs sets it before the next request.
116const effort = atom({ plugin: 'sidebar-bridge', key: 'effort' } as const, null as SidebarBridgeEffort)
117
118const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
119
120// Rows the context is made of besides the conversation: kept from the last full
121// breakdown. Messages, Free space and the compact buffer are worked out again.
122const MESSAGES = 'Messages'
123
124async function measureBreakdown($: EngineInterface) {
125  const usage = await $.session.usage({ breakdown: 'full' })
126  const b = usage.context.breakdown
127  if (!b) return
128  const now = await $.clock.now()
129  const messagesTokens = b.categories.find(c => c.name === MESSAGES)?.tokens ?? 0
130  await update($, breakdown, () => ({
131    rows: b.categories
132      .filter(c => c.kind === 'used' && c.name !== MESSAGES)
133      .map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
134    messagesTokens,
135    inputTokensAtMeasure: usage.context.tokens ?? null,
136    windowTokens: b.rawMaxTokens,
137    mcpTools: b.mcpTools.map(t => ({ name: t.name, server: t.serverName, tokens: t.tokens })),
138    memoryFiles: b.memoryFiles.map(f => ({ path: f.path, tokens: f.tokens })),
139    measuredAt: now,
140  }))
141  await sendBreakdown($)
142}
143
144// Sends the breakdown with Messages, Free space and the buffer for now.
145//
146// usage.context.tokens is the last API response's input: the conversation plus
147// the fixed rows. Right after a compact no response has come back, so it is
148// absent or still the pre-compact figure; the full count made then is the only
149// true Messages. So the full count's Messages stands until the input tokens
150// move on from what they were when it ran, and after that Messages is what is
151// left of them once the fixed rows are taken out.
152async function sendBreakdown($: EngineInterface) {
153  const b = await read($, breakdown)
154  if (!b) return
155  const usage = await $.session.usage()
156  const fixed = b.rows.reduce((sum, r) => sum + r.tokens, 0)
157  const input = usage.context.tokens
158  const isNewer = input !== undefined && input !== b.inputTokensAtMeasure
159  const messages = isNewer ? Math.max(0, input - fixed) : b.messagesTokens
160  const buffer = Math.min(3000, b.windowTokens)
161  const used = fixed + messages
162  const rows: SidebarBridgeContextRow[] = [
163    ...b.rows,
164    { name: MESSAGES, tokens: messages, kind: 'used' },
165    { name: 'Compact buffer', tokens: buffer, kind: 'buffer' },
166    { name: 'Free space', tokens: Math.max(0, b.windowTokens - used - buffer), kind: 'free' },
167  ]
168  await send($, 'statusline', {
169    context_breakdown: {
170      window_tokens: b.windowTokens,
171      used_tokens: used,
172      measured_at: b.measuredAt,
173      rows,
174      mcp_tools: [...b.mcpTools].sort((x, y) => y.tokens - x.tokens),
175      memory_files: [...b.memoryFiles].sort((x, y) => y.tokens - x.tokens),
176    },
177  })
178}
179
180// The app writes `{ "action": "refresh" | "compact" }` to this file in the
181// session's status directory when the person presses a sidebar button. The mod
182// polls it, as the app has no way to call into the session.
183const REQUEST_POLL_MS = 1000
184
185async function requestPath($: EngineInterface): Promise<string | undefined> {
186  const dir = await $.env.get('CLAUDE_TERMINAL_STATUS_DIR')
187  return dir ? `${dir}/mod-request.json` : undefined
188}
189
190type Request = { action?: unknown; value?: unknown }
191
192// Reads and consumes a pending request. The file is emptied, not removed:
193// $.fs has no delete. An empty or unreadable file is no request.
194async function takeRequest($: EngineInterface): Promise<Request | undefined> {
195  const path = await requestPath($)
196  if (!path || !(await $.fs.exists(path))) return undefined
197  const text = await $.fs.read(path).catch(() => '')
198  if (!text.trim()) return undefined
199  await $.fs.write(path, '')
200  try {
201    const request = JSON.parse(text) as Request
202    return typeof request?.action === 'string' ? request : undefined
203  } catch {
204    return undefined
205  }
206}
207
208// The open pull request for the session's branch. No native source names it,
209// so `gh pr view` asks GitHub. It is a network call, so it runs on its own,
210// slower poll, and again after a Bash call that created or changed a PR.
211const PR_POLL_MS = 60_000
212const PR_TIMEOUT_MS = 10_000
213
214// The statusLine `pr` block the binary reads: an object, or null for none.
215type PrBlock = { number: number; url: string; review_state: string } | null
216
217// What `gh pr view --json` prints for the fields asked.
218type GhPr = { number: number; url: string; state: string; isDraft: boolean; reviewDecision: string }
219
220// The sidebar's review labels: draft, approved, changes_requested, pending.
221function reviewState(pr: GhPr): string {
222  if (pr.isDraft) return 'draft'
223  switch (pr.reviewDecision) {
224    case 'APPROVED': return 'approved'
225    case 'CHANGES_REQUESTED': return 'changes_requested'
226    case 'REVIEW_REQUIRED': return 'pending'
227    default: return ''
228  }
229}
230
231// undefined when gh could not answer (not installed, signed out, offline):
232// the binary then keeps the PR it last saw. `gh pr view` exits 1 with "no pull
233// requests found" when the branch has none, which is a real answer.
234async function lookUpPr($: EngineInterface): Promise<PrBlock | undefined> {
235  try {
236    const { exitCode, stdout, stderr } = await $.process.run(
237      ['gh', 'pr', 'view', '--json', 'number,url,state,isDraft,reviewDecision'],
238      { timeoutMs: PR_TIMEOUT_MS },
239    )
240    if (exitCode !== 0) return /no pull requests found/i.test(stderr) ? null : undefined
241    const pr = JSON.parse(stdout) as GhPr
242    if (pr.state !== 'OPEN') return null
243    return { number: pr.number, url: pr.url, review_state: reviewState(pr) }
244  } catch {
245    return undefined
246  }
247}
248
249async function sendPr($: EngineInterface) {
250  const pr = await lookUpPr($)
251  if (pr !== undefined) await send($, 'statusline', { pr })
252}
253
254// A model or effort the person picked in the sidebar's model menu. Each runs
255// the slash command, as typing it would, for this session only. The value
256// comes from a file, so only a plain model name or a known level is passed on.
257const MODEL_NAME = /^[A-Za-z0-9][A-Za-z0-9._\-]*(\[[A-Za-z0-9]+\])?$/
258
259async function switchTo($: EngineInterface, action: 'model' | 'effort', value: unknown) {
260  if (typeof value !== 'string') return
261  if (action === 'model' ? !MODEL_NAME.test(value) : !EFFORTS.includes(value)) return
262  try {
263    await $.command.run({ command: action, args: value })
264    if (action === 'effort') await update($, effort, () => value)
265  } catch (err) {
266    $.ui.log(`sidebar-bridge: /${action} ${value} failed: ${String(err)}`)
267  }
268  await statusLine($)
269}
270
271// The statusLine payload, built from $.session.usage(). Cost is the session's
272// cumulative total, as total_cost_usd was, so the binary's /clear baseline still
273// subtracts; the two durations are cumulative the same way. The pull request
274// comes from sendPr, the context breakdown from sendBreakdown. No native source gives Claude Code's own session name.
275async function statusLine($: EngineInterface) {
276  const usage = await $.session.usage()
277  const begun = await read($, startedAt)
278  const durationMs = begun > 0 ? Math.max(0, (await $.clock.now()) - begun) : 0
279  const settings = ((await $.settings.read().catch(() => ({}))) ?? {}) as { model?: unknown; effortLevel?: unknown }
280  // Before the first request, the saved effort is the one it will use.
281  const level = (await read($, effort)) ?? (typeof settings.effortLevel === 'string' ? settings.effortLevel : null)
282  await send($, 'statusline', {
283    model: { id: await $.session.model() },
284    ...(level ? { effort: level } : {}),
285    ...(typeof settings.model === 'string' ? { default_model: settings.model } : {}),
286    cost: {
287      total_cost_usd: usage.cost?.usd ?? 0,
288      total_duration_ms: durationMs,
289      total_api_duration_ms: await read($, apiMs),
290    },
291  }, { repeats: true })
292}
293
294// This module load's timers. A reload starts a fresh module, and the engine
295// drops the old one's timers, so module scope holds them.
296const timers: { status?: Timer; pr?: Timer; request?: Timer } = {}
297let isCompacting = false
298
299// Runs the request the app wrote. Refresh measures the breakdown again;
300// Compact compacts the main conversation, then session.compact measures;
301// model and effort switch the session's.
302async function serveRequest($: EngineInterface) {
303  const request = await takeRequest($)
304  const action = request?.action
305  if (action === 'model' || action === 'effort') {
306    await switchTo($, action, request?.value)
307  } else if (action === 'refresh') {
308    await measureBreakdown($)
309  } else if (action === 'compact' && !isCompacting) {
310    isCompacting = true
311    await send($, 'statusline', { compacting: true })
312    try {
313      await $.session.compact()
314    } catch (err) {
315      $.ui.log(`sidebar-bridge: compact failed: ${String(err)}`)
316    } finally {
317      isCompacting = false
318      await send($, 'statusline', { compacting: false })
319    }
320  }
321}
322
323// One status poll at a time: a tick that finds the last one still queued
324// behind slow helper runs skips, as the next tick carries the same figures.
325let isPolling = false
326
327async function pollStatus($: EngineInterface) {
328  if (isPolling) return
329  isPolling = true
330  try {
331    await statusLine($)
332  } finally {
333    isPolling = false
334  }
335}
336
337function startPolls($: EngineInterface) {
338  stopPolls()
339  timers.status = $.clock.every(STATUS_POLL_MS, () => void pollStatus($))
340  timers.pr = $.clock.every(PR_POLL_MS, () => void sendPr($))
341  timers.request = $.clock.every(REQUEST_POLL_MS, () => void serveRequest($))
342}
343
344function stopPolls() {
345  timers.status?.cancel()
346  timers.pr?.cancel()
347  timers.request?.cancel()
348  timers.status = timers.pr = timers.request = undefined
349}
350
351export const register: Register = on => {
352  // An "ask" verdict's wait, by tool_use_id, until the call is answered.
353  const pendingAsks = new Map<string, Timer>()
354
355  on('session.start', async ($, e, next) => {
356    // Fires on each module load. Only the first per process is a session start;
357    // a hot reload must not reset the sidebar.
358    const result = await next(e)
359    if (!(await read($, started))) {
360      await update($, started, () => true)
361      const now = await $.clock.now()
362      await update($, startedAt, () => now)
363      await hook($, { hook_event_name: 'SessionStart', source: 'startup', model: await $.session.model() })
364    }
365    startPolls($)
366    await statusLine($)
367    void sendPr($)
368    // A reload keeps the last breakdown in $.state; a fresh session measures.
369    if (await read($, breakdown)) void sendBreakdown($)
370    else void measureBreakdown($)
371    return result
372  })
373
374  on('session.end', async ($, e, next) => {
375    stopPolls()
376    const result = await next(e)
377    // /clear ends the session with no session.start after it. The binary's
378    // SessionStart with source "clear" resets the sidebar and sets the cost
379    // baseline. The process lives on, so the poll starts again.
380    if (e.reason === 'clear') {
381      // usage.startedAt starts over at /clear; the turn time does too. The
382      // binary snapshots the first poll after this as its baseline, which is
383      // right whether a figure restarts here or keeps counting.
384      await update($, apiMs, () => 0)
385      const now = await $.clock.now()
386      await update($, startedAt, () => now)
387      await hook($, { hook_event_name: 'SessionStart', source: 'clear', model: await $.session.model() })
388      startPolls($)
389      void measureBreakdown($)
390    }
391    return result
392  })
393
394  // A compact rewrites the conversation: measure the context it left. Only the
395  // main conversation's, and not a precompute that may never be applied.
396  on('session.compact', async ($, e, next) => {
397    const result = await next(e)
398    if (!e.agentId && e.trigger !== 'precompute' && !('skip' in result && result.skip)) {
399      void measureBreakdown($)
400    }
401    return result
402  })
403
404  on('session.measure', async ($, e, next) => {
405    const result = await next(e)
406    await statusLine($)
407    return result
408  })
409
410  on('prompt.submit', async ($, e, next) => {
411    const result = await next(e)
412    // Only the person's own prompts start a turn the sidebar counts. A
413    // subagent's hand-back and other injected prompts arrive here too.
414    if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
415      await hook($, { hook_event_name: 'UserPromptSubmit', prompt: e.text })
416    }
417    return result
418  })
419    // Sidebar bookkeeping only: if it fails, pass the engine's own answer on.
420    .catch(($, e, next) => next(e))
421
422  on('tool.call', async ($, e, next) => {
423    const { tool, tool_use_id, agentId, ...toolInput } = e
424    const ids = {
425      tool_name: tool,
426      tool_use_id,
427      ...(agentId ? { agent_id: agentId } : {}),
428    }
429    await hook($, { hook_event_name: 'PreToolUse', ...ids, tool_input: toolInput })
430    const result = await next(e)
431    // next(e) holds through the permission check: the ask, if any, is settled.
432    pendingAsks.get(tool_use_id)?.cancel()
433    pendingAsks.delete(tool_use_id)
434    await hook($, {
435      hook_event_name: result.isError ? 'PostToolUseFailure' : 'PostToolUse',
436      ...ids,
437      tool_input: toolInput,
438    })
439    // A Bash call that touched a PR (gh pr create, merge, ready, ...) or moved
440    // the branch reports it in gitOperation: look the PR up now, not in 60 s.
441    if (tool === 'Bash' && !result.isError && result.result) {
442      const op = (result.result as { gitOperation?: { pr?: unknown; push?: unknown; branch?: unknown } }).gitOperation
443      if (op?.pr || op?.push || op?.branch) void sendPr($)
444    }
445    // TaskCreated: the task's id exists only in the tool's result.
446    if (tool === 'TaskCreate' && !result.isError && result.result) {
447      const { task } = result.result as { task?: { id: string; subject: string } }
448      if (task) await hook($, { hook_event_name: 'TaskCreated', task_id: task.id, task_subject: task.subject })
449    }
450    return result
451  })
452    // Sidebar bookkeeping only: if it fails, pass the engine's own answer on.
453    .catch(($, e, next) => next(e))
454
455  // A spawn's answer carries the agent's id. Its turn.complete is SubagentStop.
456  on('agent.spawn', async ($, e, next) => {
457    const result = await next(e)
458    if (result.agentId) {
459      await hook($, {
460        hook_event_name: 'SubagentStart',
461        agent_id: result.agentId,
462        agent_type: e.subagentType,
463        description: e.description,
464      })
465    }
466    return result
467  })
468    // Sidebar bookkeeping only: if it fails, pass the engine's own answer on.
469    .catch(($, e, next) => next(e))
470
471  on('turn.complete', async ($, e, next) => {
472    const result = await next(e)
473    if (!e.agentId) await update($, apiMs, ms => ms + e.durationMs)
474    await hook($, e.agentId ? { hook_event_name: 'SubagentStop', agent_id: e.agentId } : { hook_event_name: 'Stop' })
475    // The main context grew by this turn: Messages follows its input tokens.
476    if (!e.agentId) await sendBreakdown($)
477    return result
478  })
479
480  // The level each main-loop request goes out with: an /effort the person
481  // typed, or the model's own default after a /model, shows from here.
482  on('turn.step', async function* ($, e, next) {
483    if (!e.agentId && typeof e.effort === 'string' && e.effort !== (await read($, effort))) {
484      const level = e.effort
485      await update($, effort, () => level)
486      void statusLine($)
487    }
488    return yield* next(e)
489  })
490    // Sidebar bookkeeping only: if it fails, pass the request on as it was.
491    .catch(async function* ($, e, next) {
492      return yield* next(e)
493    })
494
495  // A permission ask: the Notification the binary reads as "needs input". It
496  // waits ASK_SETTLE_MS for the mode to settle the ask; the call's own
497  // tool.call hook, below, cancels the wait once the call is answered.
498  on('tool.check', async ($, e, next) => {
499    const result = await next(e)
500    const id = e.tool_use_id
501    if (result.decision === 'ask' && !result.decider && id) {
502      pendingAsks.get(id)?.cancel()
503      pendingAsks.set(
504        id,
505        $.clock.after(ASK_SETTLE_MS, () => {
506          pendingAsks.delete(id)
507          void hook($, {
508            hook_event_name: 'Notification',
509            notification_type: 'permission_prompt',
510            ...(e.agentId ? { agent_id: e.agentId } : {}),
511          })
512        }),
513      )
514    }
515    return result
516  })
517    // Sidebar bookkeeping only: if it fails, pass the engine's own answer on.
518    .catch(($, e, next) => next(e))
519}
520
types/index.d.ts 52 lines
1/** Whether this process already sent its SessionStart. */
2export type SidebarBridgeStarted = boolean
3
4/** Milliseconds of main-loop turn time since the session started. */
5export type SidebarBridgeApiMs = number
6
7/** When this process's session began, in $.clock.now() milliseconds. */
8export type SidebarBridgeStartedAt = number
9
10/** The main loop's effort level, as the last request or /effort set it; null until known. */
11export type SidebarBridgeEffort = string | null
12
13/** One row of the context breakdown, as /context lists it. */
14export type SidebarBridgeContextRow = {
15  name: string
16  tokens: number
17  kind: 'used' | 'free' | 'buffer' | 'deferred'
18}
19
20/**
21 * The last full /context breakdown: its fixed rows (everything but Messages,
22 * Free space and the buffer), its own Messages count, and the main context's
23 * input tokens when it ran. Until a newer response reports other input tokens,
24 * the full count's Messages stands; after one, Messages is rebuilt from it.
25 */
26export type SidebarBridgeBreakdown = {
27  rows: SidebarBridgeContextRow[]
28  messagesTokens: number
29  inputTokensAtMeasure: number | null
30  windowTokens: number
31  mcpTools: { name: string; server: string; tokens: number }[]
32  memoryFiles: { path: string; tokens: number }[]
33  measuredAt: number
34} | null
35
36declare module 'claude-code' {
37  interface PluginState {
38    'sidebar-bridge': {
39      /** True once this process sent its SessionStart; a hot reload keeps it. */
40      started: SidebarBridgeStarted
41      /** Summed turn.complete durationMs of the main loop. */
42      apiMs: SidebarBridgeApiMs
43      /** Set at the first session.start and at /clear. */
44      startedAt: SidebarBridgeStartedAt
45      /** The last full breakdown; null until one has run. */
46      breakdown: SidebarBridgeBreakdown
47      /** Set by each main-loop turn.step and by an /effort this mod runs. */
48      effort: SidebarBridgeEffort
49    }
50  }
51}
52