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

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-X.Y.Z.dmg from the latest release./Applications.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
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:
claude in your project.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.
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.
The Documents section lets you read the same markdown and images Claude is working on, without leaving the app.
.md or image onto the sidebar — to open it in a tab that covers the terminal area. The sidebar stays in place..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.
Cmd+N opens a new session. Pick a different project folder for each one.Cmd+1 … Cmd+9.Cmd+Shift+R renames the current session — helpful when ~/projects/foo isn't descriptive enough./clear, and also colors the session's tile in the menu-bar popover. Pick Remove Custom Background to revert.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.
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.
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.
| Action | Shortcut |
|---|---|
| New session | Cmd+N |
| Open session | Cmd+O |
| Close window | Cmd+W |
| Toggle sidebar | Cmd+B |
| Rename session | Cmd+Shift+R |
| Switch to window 1–9 | Cmd+1 … Cmd+9 |
| Find in terminal / open markdown doc | Cmd+F |
| Find next / previous | Cmd+G / Cmd+Shift+G |
| Zoom markdown in / out / reset | Cmd+= / Cmd+- / Cmd+0 |
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.
Source layout, build instructions, release process, and architecture notes live under docs/ and CONTRIBUTING.md.
hooks/register.ts 520 lines1import 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}
520types/index.d.ts 52 lines1/** 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