A step debugger for the agent loop: pause at prompts, model requests, responses, tool calls and results; inspect and edit them; play, step or stop.

A step debugger for the agent loop. Pause Claude Code at a prompt, a model request, a model response, a tool call, a tool result or the end of a turn; inspect the event; edit it before it goes on; then continue, step to the next event, or stop the turn.
/plugin marketplace add Hula-Hoop-AI/supermods
/plugin install debugger@supermods
/debugger opens the pane. /debugger play|pause|step|stop does the same as the buttons and works while a turn is running.
| Control | Effect |
|---|---|
| Pause | Stop at the next event, and at every event after it until the turn ends or you press Play |
| Step | Let the held event go and stop at the next one |
| Stop | Abort the running turn |
| Play / Continue | Run until the next checked breakpoint |
The events tab lists the session's events, newest first. The show dropdown above the list filters it: a checkbox per kind of event, one for subagents' events once there are any, and Select all / Deselect all. An event shows when its kind and its origin are both checked, so each checkbox counts what it would show under the others ("subagents 0 of 8" while their kinds are hidden). Two kinds are observed only, never held: skill loads (a skill's instructions entering the conversation, with their size) and web sources (each WebFetch URL and WebSearch query, once it has run). An unchecked kind is still recorded and still stops on its breakpoint, and the held event always shows. An event opens in place to its fields and to the conversation the model had before it. While an event is held, its fields are inputs:
| Held at | Editable |
|---|---|
| prompt | the prompt text |
| model request | the model and effort of that one request |
| model response | the response text (the response is held back until you continue) |
| tool call | each argument, or skip the call |
| tool result | the result as the model will read it |
The breakpoints tab picks which events Play stops on, which tools the two tool events apply to, and whether to pause inside subagents. Breakpoints and the filter are kept for the session.
None.
Events: session.start, command.run (/debugger), prompt.submit, turn.start, turn.step, tool.call, session.append (tool results), turn.complete, session.compact (to know when an event's context is gone), ui.render (its pane and the band above the prompt).
Capabilities: $.process.run (only sleep, to wait while an event is held), $.session.messages (to show the context), $.state (breakpoints and the filter), $.turn.abort, $.tool.list, $.clock.now. No network and no file access.
It rewrites what the model sends and receives only where you edit a held event, and never changes the conversation that came before it.
hooks/register.tsx 411 lines1// Agent Debugger: a step debugger for the agent loop.
2//
3// Pause points (each one a breakpoint kind, and every one of them in step mode):
4// prompt prompt.submit before the prompt enters; the text is editable
5// request turn.step before a model request; model and effort are editable
6// response turn.step the response, buffered, before it is shown or recorded; text is editable
7// tool tool.call before a tool runs; arguments are editable, or the call is skipped
8// result tool.call after a tool ran; the text the model will read is editable
9// turn-end turn.complete before the turn is reported done
10// Observed only (listed and filtered, never held):
11// skill skill.prompt a skill's instructions entering the conversation
12// source tool.call a WebFetch or WebSearch, once it has run
13//
14// Holding: a hook has 10 s of its own time, but time inside a `$` call is free, so a hold
15// waits on short `$.process.run(["sleep", ...])` calls until a control sets its decision.
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, Register, RenderSurface, ToolCallInput, TurnStepInput } from 'claude-code'
18
19import type { BreakKind, DebugEvent, DebugSettings, Edit, Effort, EventOf, Hold } from '../types'
20import { drawBand, drawPane } from './pane'
21import type { Controls } from './pane'
22import { CONTROLS, EFFORTS, clip, dbg, isStopped, pauses, record, seconds, tokens } from './state'
23import type { Control } from './state'
24
25const PANE = 'debugger'
26const TITLE = 'Debugger'
27const POLL_SECONDS = '0.2'
28const HOLD_LIMIT_MS = 30 * 60 * 1000
29
30// tool_use_id -> the text that replaces the tool's result as the model reads it.
31const resultOverrides = new Map<string, string>()
32
33// Session state, so a hot reload keeps it.
34const settings = atom({ plugin: 'debugger', key: 'settings' } as const, {
35 breaks: [],
36 tools: [],
37 toolsOff: [],
38 otherTools: true,
39 includeAgents: false,
40 hidden: [],
41 hideAgents: false,
42} as DebugSettings)
43
44// The settings as last written. A dispatch reads `$.state` as of one moment, and a hold
45// outlives it: a breakpoint changed while an event is held must count for the next one.
46let live: DebugSettings | undefined
47
48const current = async ($: EngineInterface) => (live ??= await read($, settings))
49
50async function change($: EngineInterface, fn: (s: DebugSettings) => DebugSettings) {
51 try {
52 live = await update($, settings, fn)
53 } catch {
54 live = fn(await current($)) // the setting still holds for this load
55 }
56}
57
58const shouldPause = async ($: EngineInterface, kind: BreakKind, agentId?: string, tool?: string) =>
59 pauses(await current($), kind, agentId, tool)
60
61/** Holds the calling hook until a control answers; resolves the hold with its `decision` and `edit`. */
62async function hold<E extends Edit>(
63 $: EngineInterface,
64 next: { signal: { aborted: boolean } },
65 ev: DebugEvent,
66 edit: E,
67): Promise<Hold<E>> {
68 const h: Hold<E> = { ev, edit, decision: null }
69 dbg.holds.push(h)
70 ev.status = 'paused'
71 ev.why = dbg.mode === 'step' && ev.kind !== 'turn-end' ? 'stepping' : 'breakpoint'
72 dbg.tab = 'events'
73 dbg.expandedId = ev.id
74 dbg.ctxOpen = false
75 try {
76 await refreshMessages($)
77 if (ev.agentId === undefined) ev.ctx = dbg.messages.length
78 await $.ui.open({ id: PANE, title: TITLE })
79 $.ui.invalidate('ui.render')
80 const startedAt = await $.clock.now()
81 while (h.decision === null) {
82 if (next.signal.aborted) h.decision = 'aborted'
83 else if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) h.decision = 'continue'
84 else await $.process.run(['sleep', POLL_SECONDS], { timeoutMs: 5000 })
85 }
86 } catch (error) {
87 // Anything unexpected lets the event go on unchanged, and says so.
88 h.decision ??= 'continue'
89 dbg.note = `Could not pause at #${ev.id}: ${clip(error instanceof Error ? error.message : error, 160)}`
90 } finally {
91 dbg.holds = dbg.holds.filter(one => one !== h)
92 ev.status = h.decision ?? 'continue'
93 $.ui.invalidate('ui.render')
94 }
95 return h
96}
97
98/** The controls: what a button, the band or `/debugger <what>` does. */
99async function act($: EngineInterface, what: Control) {
100 const first = dbg.holds[0]
101 if (what === 'play') {
102 dbg.mode = 'play'
103 for (const h of dbg.holds) h.decision ??= 'continue'
104 } else if (what === 'pause') {
105 dbg.mode = 'step'
106 } else if (what === 'step') {
107 dbg.mode = 'step'
108 if (first) first.decision ??= 'step'
109 } else if (what === 'skip') {
110 if (first?.ev.kind === 'tool') first.decision ??= 'skip'
111 } else {
112 for (const h of dbg.holds) h.decision ??= 'stop'
113 if (dbg.turnId !== null) await $.turn.abort({ turnId: dbg.turnId }).catch(() => {}) // the turn had already ended
114 }
115 $.ui.invalidate('ui.render')
116}
117
118async function refreshMessages($: EngineInterface) {
119 try {
120 dbg.messages = (await $.session.messages()).map(m => ({
121 role: m.role,
122 text: m.text,
123 tools: m.toolUses.map(use => ({ tool: use.tool, input: use.input })),
124 results: (m.toolResults ?? []).map(one => ({ text: one.text, isError: one.isError })),
125 }))
126 } catch {
127 // keep the last read
128 }
129}
130
131/** After a compaction, earlier events' context is no longer what the model reads. */
132function forgetContext($: EngineInterface, liveStart: number) {
133 for (const ev of dbg.events) ev.ctx = null
134 dbg.liveStart = liveStart
135 dbg.ctxOpen = false
136 $.ui.invalidate('ui.render')
137}
138
139/** The breakpoint list's tools: the built-in ones, and any other this session has called. */
140async function loadTools($: EngineInterface) {
141 try {
142 const listed = (await $.tool.list()).map(one => one.name).filter(name => !name.startsWith('mcp__'))
143 const called = dbg.events.flatMap(ev => (ev.kind === 'tool' ? [ev.detail.tool] : []))
144 const names = [...new Set([...listed, ...called])].sort()
145 await change($, s => {
146 // A tool newly listed keeps the answer "other tools" gave for it.
147 const fresh = s.otherTools ? [] : names.filter(name => !s.tools.includes(name))
148 return { ...s, tools: names, toolsOff: [...s.toolsOff, ...fresh] }
149 })
150 } catch {
151 // keep the last list
152 }
153}
154
155// What the pane's buttons do, as plain functions: the validator follows `$` only in this file.
156const bind = ($: EngineInterface, surface: RenderSurface): Controls => ({
157 hasFields: surface !== 'mobile',
158 act: what => void act($, what),
159 set: fn => void change($, fn),
160 redraw: () => $.ui.invalidate('ui.render'),
161 loadTools: () => void loadTools($),
162 refresh: () => refreshMessages($),
163})
164
165const isControl = (s: string): s is Control => (CONTROLS as readonly string[]).includes(s)
166
167function withStepEdits(e: TurnStepInput, edit: { model: string; effort: string }, ev: EventOf<'request'>): TurnStepInput {
168 const model = edit.model.trim() || e.model
169 const effort: Effort | undefined =
170 edit.effort === '' ? e.effort : (EFFORTS.find(x => x === edit.effort) ?? Number(edit.effort))
171 if (model === e.model && effort === e.effort) return e
172 ev.detail = { ...ev.detail, sentModel: model, sentEffort: effort }
173 return { ...e, model, effort }
174}
175
176/** Arguments as the fields show them: strings as they are, everything else as JSON. */
177function editable(args: Record<string, unknown>) {
178 return Object.fromEntries(Object.entries(args).map(([key, value]) => [key, typeof value === 'string' ? value : JSON.stringify(value)]))
179}
180
181/** The arguments whose field changed, parsed back; a field that no longer parses keeps the original. */
182function parsedEdits(args: Record<string, unknown>, edited: Record<string, string>) {
183 const before = editable(args)
184 const out: Record<string, unknown> = {}
185 for (const key of Object.keys(args)) {
186 const text = edited[key]
187 if (text === undefined || text === before[key]) continue
188 if (typeof args[key] === 'string') {
189 out[key] = text
190 continue
191 }
192 try {
193 out[key] = JSON.parse(text)
194 } catch {
195 dbg.note = `Kept the original "${key}": the edit was not valid JSON.`
196 }
197 }
198 return out
199}
200
201/** A web source from a WebFetch or WebSearch call, once it has run. */
202function recordSource(tool: string, args: Record<string, unknown>, ran: { deny?: string; isError?: boolean; result?: unknown }, agentId?: string) {
203 const ok = ran.deny === undefined && ran.isError !== true
204 if (tool === 'WebSearch') {
205 const query = typeof args.query === 'string' ? args.query : ''
206 if (!query) return
207 const results = searchHits(ran.result)
208 record('source', `search “${clip(query, 50)}” → ${results} results`, { tool, query, results, ok }, agentId)
209 } else {
210 const url = typeof args.url === 'string' ? args.url : ''
211 if (!url) return
212 record('source', `fetch ${clip(url, 70)}${ok ? '' : ' failed'}`, { tool, url, ok }, agentId)
213 }
214}
215
216function searchHits(result: unknown) {
217 const results = result && typeof result === 'object' ? (result as { results?: unknown }).results : undefined
218 if (!Array.isArray(results)) return 0
219 return results.reduce<number>((n, block) => n + (Array.isArray((block as { content?: unknown })?.content) ? (block as { content: unknown[] }).content.length : 0), 0)
220}
221
222function describeResponse(result: { answer: string; toolUses: readonly { name: string }[]; stopReason: string | null }) {
223 const tools = result.toolUses.map(use => use.name).join(', ')
224 return [clip(result.answer, 50), tools && `→ ${tools}`, result.stopReason && `(${result.stopReason})`].filter(Boolean).join(' ') || '(empty)'
225}
226
227export const register: Register = on => {
228 on('session.start', async ($, e, next) => {
229 await $.command.register({
230 name: 'debugger',
231 description: 'Open the agent debugger, or control it: play, pause, step, stop',
232 argumentHint: `[${CONTROLS.join('|')}]`,
233 immediate: true,
234 })
235 await loadTools($)
236 return next(e)
237 })
238
239 on('command.run', { command: 'debugger' }, async ($, e) => {
240 const what = e.args.trim().toLowerCase()
241 if (isControl(what)) {
242 await act($, what)
243 return { text: `Debugger: ${what}.` }
244 }
245 const opened = await $.ui.open({ id: PANE, title: TITLE })
246 await refreshMessages($)
247 await loadTools($)
248 return {
249 text: opened.isPlaced
250 ? 'Debugger pane opened.'
251 : `The Debugger pane is open, but this surface is not showing it: ${opened.reason}\nUse /debugger ${CONTROLS.join('|')}.`,
252 }
253 })
254
255 on('prompt.submit', async ($, e, next) => {
256 // Only the person's own idle prompt is a pause point; commands and deliveries pass.
257 if (e.origin?.kind !== 'composer' || e.turnId !== undefined || e.text.startsWith('/')) return next(e)
258 const ev = record('prompt', clip(e.text, 80), { text: e.text })
259 if (!(await shouldPause($, 'prompt'))) return next(e)
260 const h = await hold($, next, ev, { kind: 'prompt', text: e.text })
261 if (isStopped(h)) return { drop: 'Stopped in the debugger before the prompt was sent.' }
262 if (h.edit.text === e.text) return next(e)
263 ev.detail = { ...ev.detail, editedText: h.edit.text }
264 return next({ ...e, text: h.edit.text })
265 })
266
267 on('turn.start', ($, e, next) => {
268 dbg.turnId = e.turnId
269 $.ui.invalidate('ui.render')
270 return next(e)
271 })
272
273 on('turn.step', async function* ($, e, next) {
274 if (e.agentId === undefined) await refreshMessages($)
275 const { model, effort, messageCount } = e
276 const ev = record('request', `${model} · ${messageCount} messages`, { model, effort, messageCount }, e.agentId)
277 let input: TurnStepInput = e
278 if (await shouldPause($, 'request', e.agentId)) {
279 const h = await hold($, next, ev, { kind: 'request', model, effort: effort === undefined ? '' : String(effort) })
280 if (isStopped(h)) return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: null, usage: null }
281 input = withStepEdits(e, h.edit, ev)
282 }
283
284 if (!(await shouldPause($, 'response', e.agentId))) {
285 const result = yield* next(input)
286 record('response', describeResponse(result), result, e.agentId)
287 $.ui.invalidate('ui.render')
288 return result
289 }
290
291 // Buffered: nothing is shown or recorded until the hold is answered.
292 const chunks = []
293 const stream = next(input)
294 for await (const chunk of stream) chunks.push(chunk)
295 const result = await stream.result
296 const texts: Record<string, string> = {}
297 for (const chunk of chunks) {
298 if (chunk.kind === 'text') texts[chunk.index] = (texts[chunk.index] ?? '') + chunk.text
299 }
300 const rev = record('response', describeResponse(result), result, e.agentId)
301 const { edit } = await hold($, next, rev, { kind: 'response', texts: { ...texts } })
302 const edited = Object.keys(texts).filter(index => edit.texts[index] !== texts[index])
303 if (edited.length === 0) {
304 yield* chunks
305 return result
306 }
307 rev.detail = { ...rev.detail, editedText: edit.texts }
308 const sent = new Set<number>()
309 for (const chunk of chunks) {
310 if (chunk.kind !== 'text' || !edited.includes(String(chunk.index))) {
311 yield chunk
312 } else if (!sent.has(chunk.index)) {
313 sent.add(chunk.index)
314 // An emptied block is dropped by yielding none of its chunks.
315 const text = edit.texts[chunk.index]
316 if (text) yield { ...chunk, text }
317 }
318 }
319 return { ...result, answer: Object.keys(texts).map(index => edit.texts[index]).join('') }
320 })
321
322 on('tool.call', async ($, e, next) => {
323 const { tool, tool_use_id: id, agentId, ...args }: ToolCallInput & Record<string, unknown> = e
324 const toolName: string = tool
325 const ev = record('tool', `${tool} ${clip(JSON.stringify(args), 60)}`, { tool, args }, agentId)
326 let input = e
327 if (await shouldPause($, 'tool', agentId, tool)) {
328 const h = await hold($, next, ev, { kind: 'tool', args: editable(args) })
329 if (isStopped(h)) return { deny: 'The user stopped this turn in the debugger before the call ran. Do not retry it unless asked.' }
330 if (h.decision === 'skip') return { deny: 'The user skipped this tool call in the debugger. Do not retry it unless asked.' }
331 const changed = parsedEdits(args, h.edit.args)
332 if (Object.keys(changed).length > 0) {
333 ev.detail = { ...ev.detail, editedArgs: changed }
334 input = { ...e, ...changed }
335 }
336 }
337
338 const ran = await next(input)
339 const text = ran.deny !== undefined ? `denied: ${ran.deny}` : (ran.text ?? '')
340 const isError = ran.isError === true
341 const rev = record('result', `${tool} ${isError ? 'error ' : ''}${clip(text, 60)}`, { tool, isError, text }, agentId)
342 if (ran.deny === undefined && (await shouldPause($, 'result', agentId, tool))) {
343 const h = await hold($, next, rev, { kind: 'result', text })
344 if (h.edit.text !== text && h.decision !== 'aborted' && id !== undefined) {
345 rev.detail = { ...rev.detail, editedText: h.edit.text }
346 resultOverrides.set(id, h.edit.text)
347 }
348 }
349 if (tool === 'WebFetch' || tool === 'WebSearch') recordSource(toolName, args, ran, agentId)
350 $.ui.invalidate('ui.render')
351 return ran
352 })
353
354 on('skill.prompt', async ($, e, next) => {
355 const r = await next(e)
356 record('skill', `${e.skill} ${tokens(r.text.length)}`, { skill: e.skill, chars: r.text.length })
357 $.ui.invalidate('ui.render')
358 return r
359 })
360
361 // An edited tool result is rewritten where the row is stored, so the model reads the edit.
362 on('session.append', { door: 'tool-result' }, ($, e, next) => {
363 if (resultOverrides.size === 0) return next(e)
364 let isChanged = false
365 const content = e.message.content.map(block => {
366 const id = block.type === 'tool_result' && typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
367 const text = id === undefined ? undefined : resultOverrides.get(id)
368 if (id === undefined || text === undefined) return block
369 resultOverrides.delete(id)
370 isChanged = true
371 return { ...block, content: [{ type: 'text' as const, text }] }
372 })
373 return isChanged ? next({ ...e, message: { ...e.message, content } }) : next(e)
374 })
375
376 on('turn.complete', async ($, e, next) => {
377 if (e.agentId !== undefined) return next(e)
378 const ev = record('turn-end', `${e.reason} · ${seconds(e.durationMs)}`, { reason: e.reason, durationMs: e.durationMs, usage: e.usage ?? null })
379 // Stepping ends with its turn: the turn's end stops only on its own breakpoint,
380 // and the next turn runs to breakpoints again.
381 if (e.reason !== 'aborted' && (await current($)).breaks.includes('turn-end')) {
382 await hold($, next, ev, { kind: 'turn-end' })
383 }
384 dbg.mode = 'play'
385 dbg.turnId = null
386 $.ui.invalidate('ui.render')
387 return next(e)
388 })
389
390 on('session.compact', async ($, e, next) => {
391 if (e.agentId !== undefined || e.trigger === 'precompute') return next(e)
392 const rowsBefore = await $.session.messages().then(rows => rows.length, () => dbg.messages.length)
393 const out = await next(e)
394 if (out.skip === undefined) forgetContext($, rowsBefore)
395 return out
396 })
397
398 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) =>
399 drawPane($.ui.resolve(e), await read($, settings), bind($, e.surface)),
400 )
401
402 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
403 if (dbg.holds.length === 0 || e.props?.hasSurvey) return next(e)
404 const t = $.ui.resolve(e)
405 const { Box } = t
406 const below = await next(e)
407 const row = drawBand(t, bind($, e.surface))
408 return below ? <Box flexDirection="column">{[row, below]}</Box> : row
409 })
410}
411hooks/pane.tsx 451 lines1// The pane is one list of events, newest first. An event opens in place to its fields
2// (inputs while it is held) and to the conversation the model had before it.
3import type { ElementTable } from 'claude-code'
4
5import type { DebugEvent, DebugSettings, Hold, Kind, Usage } from '../types'
6import { BREAK_KINDS, EFFORTS, KINDS, KIND_LABEL, LIST_STEP, clean, clip, dbg, flip, seconds, tokens } from './state'
7import type { Control, Tab } from './state'
8
9const CONTEXT_MAX = 300
10const LINE_CHARS = 400
11const LONG_VALUE = 60
12const MAX_LINES = 40
13const VALUE_CHARS = 3000
14const TABS: Tab[] = ['events', 'breakpoints']
15
16type UI = ElementTable
17
18// What the buttons do; register.tsx binds these to `$`.
19export type Controls = {
20 hasFields: boolean // false where the surface draws no Input or Select (mobile)
21 act: (what: Control) => void
22 set: (change: (s: DebugSettings) => DebugSettings) => void
23 redraw: () => void
24 loadTools: () => void
25 refresh: () => Promise<void> // reads the conversation again
26}
27type Field = [label: string, value: string]
28
29const shown = (value: unknown) => (typeof value === 'string' ? value : JSON.stringify(value))
30const usageText = (usage: Usage) => (usage ? `${usage.input_tokens ?? '?'} in · ${usage.output_tokens ?? '?'} out` : 'none reported')
31const check = (isOn: boolean, label: string) => `${isOn ? '[x]' : '[ ]'} ${label}`
32const plural = (kind: Kind) => `${KIND_LABEL[kind]}s`
33const isArmed = (s: DebugSettings) => s.breaks.includes('tool') || s.breaks.includes('result')
34
35/** Picking tools means stopping on them: with neither tool event checked, "tool call" is checked too. */
36const armTools = (s: DebugSettings): DebugSettings => (isArmed(s) ? s : { ...s, breaks: [...s.breaks, 'tool'] })
37
38function toolSummary(s: DebugSettings) {
39 const on = s.tools.filter(name => !s.toolsOff.includes(name))
40 const total = s.tools.length + 1
41 const count = on.length + (s.otherTools ? 1 : 0)
42 if (count === total) return 'all tools'
43 if (count === 0) return 'no tools'
44 return on.length <= 3 && !s.otherTools ? on.join(', ') : `${count} of ${total} tools`
45}
46
47function filterSummary(s: DebugSettings, hasAgents: boolean) {
48 const on = KINDS.filter(kind => !s.hidden.includes(kind))
49 const kinds =
50 on.length === KINDS.length ? 'all events' : on.length === 0 ? 'no events' : on.length <= 2 ? on.map(plural).join(', ') : `${on.length} of ${KINDS.length} kinds`
51 return hasAgents && s.hideAgents ? `${kinds}, no subagents` : kinds
52}
53
54/** How many a row would show given the other checks, against how many there are. */
55const ofTotal = (shown: number, total: number) => (shown === total ? `${total}` : `${shown} of ${total}`)
56
57function allNoneRow(t: UI, prefix: string, onAll: () => void, onNone: () => void) {
58 const { Box, Button } = t
59 return (
60 <Box flexDirection="row" gap={3} marginLeft={2}>
61 <Button key={`${prefix}-all`} plain onPress={onAll}>Select all</Button>
62 <Button key={`${prefix}-none`} plain onPress={onNone}>Deselect all</Button>
63 </Box>
64 )
65}
66
67function rowOf(t: UI, key: string, label: string, onPress: () => void, indent = 0) {
68 const { Box, Button } = t
69 return (
70 <Box flexDirection="row" marginLeft={indent}>
71 <Button key={key} plain onPress={onPress}>{label}</Button>
72 </Box>
73 )
74}
75
76function statusLine(s: DebugSettings) {
77 const h = dbg.holds[0]
78 if (h) {
79 const queued = dbg.holds.length > 1 ? ` (+${dbg.holds.length - 1} waiting)` : ''
80 const why = h.ev.why === 'stepping' ? 'stepping: every event stops until you press Continue or the turn ends' : 'breakpoint'
81 return `⏸ PAUSED at ${KIND_LABEL[h.ev.kind]} (${why}) · ${clip(h.ev.label, 50)}${queued}`
82 }
83 const armed =
84 dbg.mode === 'step'
85 ? 'stepping: stops at every event until you press Play or the turn ends'
86 : s.breaks.length > 0
87 ? `breakpoints: ${s.breaks.map(kind => KIND_LABEL[kind]).join(', ')}${isArmed(s) ? ` · ${toolSummary(s)}` : ''}`
88 : 'no breakpoints'
89 return `${dbg.turnId !== null ? '● RUNNING' : '○ IDLE'} · ${armed}`
90}
91
92export function drawPane(t: UI, s: DebugSettings, io: Controls) {
93 const { Box, Text, Button } = t
94 const press = (what: Control) => () => io.act(what)
95 const isPaused = dbg.holds.length > 0
96 return (
97 <Box flexDirection="column" paddingX={1} gap={1}>
98 <Text bold color={isPaused ? 'yellow' : dbg.turnId !== null ? 'green' : undefined} wrap="truncate-end">
99 {statusLine(s)}
100 </Text>
101 <Box flexDirection="row" gap={1} flexWrap="wrap">
102 <Button key="pause" hotkey="p" onPress={press('pause')}>⏸ Pause</Button>
103 <Button key="step" hotkey="s" onPress={press('step')}>⏭ Step</Button>
104 <Button key="stop" hotkey="x" onPress={press('stop')}>⏹ Stop</Button>
105 <Button key="play" hotkey="c" variant={isPaused ? 'primary' : 'secondary'} onPress={press('play')}>
106 {isPaused ? '▶ Continue' : '▶ Play'}
107 </Button>
108 </Box>
109 <Box flexDirection="row" gap={1}>
110 {TABS.map(name => (
111 <Button
112 key={`tab:${name}`}
113 variant={name === dbg.tab ? 'primary' : 'secondary'}
114 onPress={() => {
115 dbg.tab = name
116 if (name === 'breakpoints') io.loadTools()
117 io.redraw()
118 }}
119 >
120 {name}
121 </Button>
122 ))}
123 </Box>
124 {dbg.note !== '' && <Text color="cyan" wrap="wrap">{clean(dbg.note)}</Text>}
125 {dbg.tab === 'breakpoints' ? drawBreakpoints(t, s, io) : drawEvents(t, s, io)}
126 </Box>
127 )
128}
129
130export function drawBand(t: UI, io: Controls) {
131 const { Box, Text, Button } = t
132 const press = (what: Control) => () => io.act(what)
133 const ev = dbg.holds[0]!.ev
134 return (
135 <Box flexDirection="row" gap={1} paddingX={1}>
136 <Text bold color="yellow" wrap="truncate-end">{`⏸ ${KIND_LABEL[ev.kind]} · ${clip(ev.label, 50)}`}</Text>
137 <Button key="band-step" plain onPress={press('step')}>Step</Button>
138 <Button key="band-stop" plain onPress={press('stop')}>Stop</Button>
139 <Button key="band-play" plain onPress={press('play')}>Continue</Button>
140 </Box>
141 )
142}
143
144/** The events the filter lets through, newest first, so the one being held is at the top; each opens in place. */
145function drawEvents(t: UI, s: DebugSettings, io: Controls) {
146 const { Box, Text, Button } = t
147 if (dbg.events.length === 0) {
148 return <Text dimColor wrap="wrap">No events yet. Press Pause to stop at the next event, or set breakpoints, then send a prompt.</Text>
149 }
150 const kindShown = (ev: DebugEvent) => !s.hidden.includes(ev.kind)
151 const agentShown = (ev: DebugEvent) => !(s.hideAgents && ev.agentId !== undefined)
152 // Each row counts what checking it shows under the other checks, so a row whose events
153 // another check hides reads "0 of n" rather than promising events that will not appear.
154 const ofKind = (kind: Kind) => dbg.events.filter(ev => ev.kind === kind)
155 const count = (kind: Kind) => ofTotal(ofKind(kind).filter(agentShown).length, ofKind(kind).length)
156 const ofAgents = dbg.events.filter(ev => ev.agentId !== undefined)
157 const agents = ofTotal(ofAgents.filter(kindShown).length, ofAgents.length)
158 // A held event shows whatever the filter says: it is what the controls act on.
159 const passing = dbg.events.filter(ev => ev.status === 'paused' || (kindShown(ev) && agentShown(ev)))
160 return (
161 <Box flexDirection="column">
162 <Box flexDirection="column" marginBottom={1}>
163 {rowOf(t, 'filter', `${dbg.filterOpen ? '▾' : '▸'} show: ${filterSummary(s, ofAgents.length > 0)}`, () => ((dbg.filterOpen = !dbg.filterOpen), io.redraw()))}
164 {dbg.filterOpen && [
165 allNoneRow(
166 t,
167 'filter',
168 () => io.set(x => ({ ...x, hidden: [], hideAgents: false })),
169 () => io.set(x => ({ ...x, hidden: [...KINDS], hideAgents: true })),
170 ),
171 ...KINDS.map(kind =>
172 rowOf(t, `filter:${kind}`, check(!s.hidden.includes(kind), `${plural(kind)} ${count(kind)}`), () => io.set(x => ({ ...x, hidden: flip(x.hidden, kind) })), 2),
173 ),
174 ofAgents.length > 0 && rowOf(t, 'filter:agents', check(!s.hideAgents, `subagents ${agents}`), () => io.set(x => ({ ...x, hideAgents: !x.hideAgents })), 2),
175 ]}
176 </Box>
177 {passing.length === 0 && <Text dimColor>{`All ${dbg.events.length} events are filtered out.`}</Text>}
178 {passing.slice(-dbg.listShown).reverse().map(ev => drawEvent(t, ev, io))}
179 {passing.length > dbg.listShown && (
180 <Button key="older" plain dimColor onPress={() => ((dbg.listShown += LIST_STEP), io.redraw())}>
181 {`Show ${Math.min(LIST_STEP, passing.length - dbg.listShown)} older events`}
182 </Button>
183 )}
184 </Box>
185 )
186}
187
188function wasEdited(ev: DebugEvent) {
189 return Object.keys(ev.detail).some(key => key.startsWith('edited') || key === 'sentModel')
190}
191
192function drawEvent(t: UI, ev: DebugEvent, io: Controls) {
193 const { Box, Button } = t
194 const isOpen = ev.id === dbg.expandedId
195 const mark = ev.status === 'paused' ? '⏸ ' : ev.status === 'skip' ? '⤫ ' : wasEdited(ev) ? '✎ ' : ''
196 return (
197 <Box flexDirection="column">
198 <Button
199 key={`ev-${ev.id}`}
200 plain
201 dimColor={!isOpen && ev.status !== 'paused'}
202 onPress={() => {
203 dbg.expandedId = isOpen ? null : ev.id
204 dbg.ctxOpen = false
205 io.redraw()
206 }}
207 >
208 {clip(`${isOpen ? '▾' : '▸'} #${ev.id} ${mark}${KIND_LABEL[ev.kind]}${ev.agentId ? ' (agent)' : ''} · ${ev.label}`, 110)}
209 </Button>
210 {isOpen && drawOpenEvent(t, ev, io)}
211 </Box>
212 )
213}
214
215/** An open event: its fields (inputs while it is held), then the conversation the model had before it. */
216function drawOpenEvent(t: UI, ev: DebugEvent, io: Controls) {
217 const { Box, Text, Button } = t
218 const held = dbg.holds.find(one => one.ev === ev)
219 return (
220 <Box flexDirection="column" borderStyle="round" borderColor={held ? 'yellow' : undefined} paddingX={1} marginLeft={2} marginBottom={1}>
221 {held ? drawEditors(t, held, io) : fieldsOf(ev).map(f => field(t, f))}
222 {ev.ctx === null ? (
223 <Text dimColor wrap="wrap">
224 {ev.agentId ? "Context is not tracked for a subagent's events." : 'The conversation was compacted after this event, so its context is gone.'}
225 </Text>
226 ) : (
227 <Box flexDirection="row" marginTop={1}>
228 <Button
229 key="ctx-toggle"
230 plain
231 onPress={async () => {
232 dbg.ctxOpen = !dbg.ctxOpen
233 await io.refresh()
234 io.redraw()
235 }}
236 >
237 {`${dbg.ctxOpen ? '▾' : '▸'} context before (${Math.max(0, ev.ctx - dbg.liveStart)} messages)`}
238 </Button>
239 </Box>
240 )}
241 {ev.ctx !== null && dbg.ctxOpen && drawTranscript(t, ev.ctx)}
242 </Box>
243 )
244}
245
246/** The live conversation up to `end` as the model reads it, as text. */
247function drawTranscript(t: UI, end: number) {
248 const { Box, Text } = t
249 const last = Math.min(end, dbg.messages.length)
250 const first = Math.max(dbg.liveStart, last - CONTEXT_MAX)
251 return (
252 <Box flexDirection="column" marginTop={1}>
253 <Text dimColor wrap="wrap">
254 {`The ${Math.max(0, last - dbg.liveStart)} messages the model has at this point, oldest first (the system prompt and tool definitions are not shown).${first > dbg.liveStart ? ` Showing the last ${CONTEXT_MAX}.` : ''}`}
255 </Text>
256 {dbg.messages.slice(first, last).map((m, at) => (
257 <Box flexDirection="column" marginTop={1}>
258 <Text bold color={m.role === 'user' ? 'cyan' : 'magenta'}>{`#${first + at - dbg.liveStart} ${m.role.toUpperCase()}`}</Text>
259 {m.text !== '' && <Text wrap="wrap">{clean(m.text).slice(0, VALUE_CHARS)}</Text>}
260 {m.tools.map(use => (
261 <Text dimColor wrap="wrap">{`→ calls ${use.tool} ${clip(shown(use.input), LINE_CHARS)}`}</Text>
262 ))}
263 {m.results.map(one => (
264 <Text dimColor wrap="wrap">{`← tool ${one.isError ? 'error' : 'result'}: ${clip(one.text, LINE_CHARS)}`}</Text>
265 ))}
266 </Box>
267 ))}
268 </Box>
269 )
270}
271
272function field(t: UI, [label, value]: Field) {
273 const { Text } = t
274 return (
275 <Text wrap="wrap">
276 <Text dimColor>{`${label} `}</Text>
277 {clean(value).slice(0, VALUE_CHARS) || '(empty)'}
278 </Text>
279 )
280}
281
282/** An event's fields as text, once it is no longer held. */
283function fieldsOf(ev: DebugEvent): Field[] {
284 const calls = (uses: readonly { name: string; input: unknown }[]) => uses.map((use): Field => ['calls', `${use.name} ${shown(use.input)}`])
285 const when = (value: unknown, label: string, text: () => string): Field[] => (value === undefined ? [] : [[label, text()]])
286 switch (ev.kind) {
287 case 'prompt': {
288 const d = ev.detail
289 return [['prompt', d.text], ...when(d.editedText, 'sent as', () => d.editedText!)]
290 }
291 case 'request': {
292 const d = ev.detail
293 return [
294 ['model', d.sentModel ?? d.model],
295 ['effort', shown(d.sentEffort ?? d.effort ?? 'default')],
296 ['messages', String(d.messageCount)],
297 ...when(d.sentModel, 'asked for', () => `${d.model} · ${shown(d.effort ?? 'default')}`),
298 ]
299 }
300 case 'response': {
301 const d = ev.detail
302 return [
303 ['text', d.answer || '(no text)'],
304 ...calls(d.toolUses),
305 ['stop', d.stopReason ?? 'none'],
306 ['tokens', usageText(d.usage)],
307 ...when(d.editedText, 'shown as', () => Object.values(d.editedText!).join('')),
308 ]
309 }
310 case 'tool': {
311 const d = ev.detail
312 return [
313 ['tool', d.tool],
314 ...Object.entries(d.args).map(([key, value]): Field => [key, shown(value)]),
315 ...Object.entries(d.editedArgs ?? {}).map(([key, value]): Field => [`${key} ran as`, shown(value)]),
316 ]
317 }
318 case 'result': {
319 const d = ev.detail
320 return [['tool', d.tool], [d.isError ? 'error' : 'result', d.text], ...when(d.editedText, 'model read', () => d.editedText!)]
321 }
322 case 'turn-end': {
323 const d = ev.detail
324 return [['ended', d.reason], ['took', seconds(d.durationMs)], ['tokens', usageText(d.usage)]]
325 }
326 case 'skill': {
327 const d = ev.detail
328 return [['skill', d.skill], ['size', tokens(d.chars)]]
329 }
330 case 'source': {
331 const d = ev.detail
332 return [
333 ['tool', d.tool],
334 ...when(d.url, 'url', () => d.url!),
335 ...when(d.query, 'query', () => d.query!),
336 ...when(d.results, 'results', () => String(d.results)),
337 ['status', d.ok ? 'ok' : 'failed'],
338 ]
339 }
340 }
341}
342
343/** A held event's fields as inputs; each writes into the hold's `edit`, read when it continues. */
344function drawEditors(t: UI, held: Hold, io: Controls) {
345 const { ev, edit } = held
346 const { Box, Text, Button } = t
347 const skip = (
348 <Box flexDirection="row">
349 <Button key={`skip-${ev.id}`} onPress={() => io.act('skip')}>Skip this call</Button>
350 </Box>
351 )
352 if (!io.hasFields || !('Input' in t)) return [...fieldsOf(ev).map(f => field(t, f)), ...(edit.kind === 'tool' ? [skip] : [])]
353 const { Input, Select } = t
354 // An Input is one line on every surface. A value of several lines gets a field per line, so it
355 // reads and edits as a block; one too long for that is shown whole above a single field.
356 const input = (key: string, label: string, value: string, write: (v: string) => void) => {
357 const one = (suffix: string, text: string, fieldLabel: string | undefined, onInput: (v: string) => void) => (
358 <Input key={`edit-${ev.id}-${key}${suffix}`} label={fieldLabel} value={text} submitLabel="✓" onInput={onInput} onSubmit={onInput} />
359 )
360 const lines = value.split('\n')
361 if (lines.length > 1 && lines.length <= MAX_LINES) {
362 return (
363 <Box flexDirection="column">
364 <Text dimColor>{label}</Text>
365 {lines.map((line, at) => one(`-${at}`, line, undefined, v => ((lines[at] = v), write(lines.join('\n')))))}
366 </Box>
367 )
368 }
369 const box = one('', value, label, write)
370 return value.length > LONG_VALUE || lines.length > 1 ? <Box flexDirection="column">{[field(t, [`${label} now`, value]), box]}</Box> : box
371 }
372 const fixed = (...labels: string[]) => fieldsOf(ev).filter(([label]) => labels.includes(label)).map(f => field(t, f))
373 switch (edit.kind) {
374 case 'prompt':
375 return [input('text', 'prompt', edit.text, v => (edit.text = v))]
376 case 'result':
377 return [...fixed('tool'), input('text', 'result', edit.text, v => (edit.text = v))]
378 case 'request':
379 return [
380 input('model', 'model', edit.model, v => (edit.model = v)),
381 <Select
382 key={`edit-${ev.id}-effort`}
383 label="effort"
384 value={edit.effort}
385 options={[{ value: '', label: 'default' }, ...EFFORTS.map(value => ({ value }))]}
386 onSelect={(v: string) => (edit.effort = v)}
387 />,
388 ...fixed('messages'),
389 ]
390 case 'response':
391 return [
392 ...Object.entries(edit.texts).map(([index, text]) => input(`text-${index}`, 'text', text, v => (edit.texts[index] = v))),
393 ...fixed('calls', 'stop'),
394 ]
395 case 'tool':
396 return [
397 ...fixed('tool'),
398 ...Object.entries(edit.args).map(([key, text]) => input(`arg-${key}`, key, text, v => (edit.args[key] = v))),
399 skip,
400 ]
401 case 'turn-end':
402 return fieldsOf(ev).map(f => field(t, f))
403 }
404 return fieldsOf(ev).map(f => field(t, f)) // the observed kinds are never held
405}
406
407function drawBreakpoints(t: UI, s: DebugSettings, io: Controls) {
408 const { Box, Text } = t
409 const row = (key: string, label: string, onPress: () => void, indent = 0) => rowOf(t, key, label, onPress, indent)
410 return (
411 <Box flexDirection="column" gap={1}>
412 <Text dimColor wrap="wrap">
413 Play runs until a checked event. Pause and Step stop at every event of the current turn, checked or not; when the turn ends, only the checked events stop again.
414 </Text>
415 <Box flexDirection="column">
416 {BREAK_KINDS.map(kind => row(`bp-${kind}`, check(s.breaks.includes(kind), KIND_LABEL[kind]), () => io.set(x => ({ ...x, breaks: flip(x.breaks, kind) }))))}
417 </Box>
418 <Box flexDirection="column">
419 {row(
420 'bp-tools',
421 `${dbg.toolsOpen ? '▾' : '▸'} tool call and tool result stop on: ${toolSummary(s)}${isArmed(s) ? '' : ' (neither is checked above)'}`,
422 () => ((dbg.toolsOpen = !dbg.toolsOpen), io.redraw()),
423 )}
424 {dbg.toolsOpen && [
425 allNoneRow(
426 t,
427 'bp-tools',
428 () => io.set(x => armTools({ ...x, toolsOff: [], otherTools: true })),
429 () => io.set(x => ({ ...x, toolsOff: x.tools, otherTools: false })),
430 ),
431 ...s.tools.map(name =>
432 row(
433 `bp-tool-${name}`,
434 check(!s.toolsOff.includes(name), name),
435 () => io.set(x => (x.toolsOff.includes(name) ? armTools({ ...x, toolsOff: flip(x.toolsOff, name) }) : { ...x, toolsOff: [...x.toolsOff, name] })),
436 2,
437 ),
438 ),
439 row(
440 'bp-tools-other',
441 check(s.otherTools, 'other tools (MCP and unlisted)'),
442 () => io.set(x => (x.otherTools ? { ...x, otherTools: false } : armTools({ ...x, otherTools: true }))),
443 2,
444 ),
445 ]}
446 </Box>
447 {row('bp-agents', check(s.includeAgents, 'also pause inside subagents'), () => io.set(x => ({ ...x, includeAgents: !x.includeAgents })))}
448 </Box>
449 )
450}
451hooks/state.ts 95 lines1// The debugger's events and holds, and what the pane has open. Module state: a hold is a live
2// hook waiting on its `decision`, so none of it outlives a reload. Breakpoints and the filter do,
3// in the `settings` atom of register.tsx.
4import type { BreakKind, DebugEvent, DebugSettings, Details, EventOf, Hold, Kind } from '../types'
5
6const MAX_EVENTS = 300
7export const LIST_STEP = 40
8
9export const BREAK_KINDS: BreakKind[] = ['prompt', 'request', 'response', 'tool', 'result', 'turn-end']
10export const KINDS: Kind[] = [...BREAK_KINDS, 'skill', 'source']
11export const KIND_LABEL: Record<Kind, string> = {
12 prompt: 'prompt',
13 request: 'model request',
14 response: 'model response',
15 tool: 'tool call',
16 result: 'tool result',
17 'turn-end': 'turn end',
18 skill: 'skill load',
19 source: 'web source',
20}
21export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
22export const CONTROLS = ['play', 'pause', 'step', 'stop'] as const
23export type Control = (typeof CONTROLS)[number] | 'skip'
24export type Tab = 'events' | 'breakpoints'
25
26type Message = {
27 role: 'user' | 'assistant'
28 text: string
29 tools: { tool: string; input: unknown }[]
30 results: { text: string; isError?: boolean }[]
31}
32
33export const dbg = {
34 // "play" runs to the next breakpoint; "step" pauses at every pause point.
35 mode: 'play' as 'play' | 'step',
36 events: [] as DebugEvent[],
37 seq: 0,
38 // Holds in the order they began; the pane acts on the first.
39 holds: [] as Hold[],
40 turnId: null as string | null,
41 messages: [] as Message[],
42 // $.session.messages() is the whole transcript, compactions included; the model reads only
43 // what follows the last one, from this row.
44 liveStart: 0,
45 note: '',
46 tab: 'events' as Tab,
47 listShown: LIST_STEP,
48 expandedId: null as number | null,
49 ctxOpen: false,
50 toolsOpen: false,
51 filterOpen: false,
52}
53
54/** Text an element may hold: tab and newline are the only control characters allowed. */
55export function clean(text: unknown) {
56 return String(text ?? '').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
57}
58
59export function clip(text: unknown, max: number) {
60 const flat = clean(text).replace(/\s+/g, ' ').trim()
61 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
62}
63
64export const seconds = (ms: number) => `${Math.round(ms / 100) / 10}s`
65
66/** A rough size for text the model reads, at four characters a token. */
67export const tokens = (chars: number) => (chars >= 4000 ? `~${(chars / 4000).toFixed(1)}k tok` : `~${Math.round(chars / 4)} tok`)
68
69export const flip = <T>(list: T[], item: T) => (list.includes(item) ? list.filter(x => x !== item) : [...list, item])
70
71export function record<K extends Kind>(kind: K, label: string, detail: Details[K], agentId?: string): EventOf<K> {
72 dbg.seq += 1
73 const ev: EventOf<K> = {
74 id: dbg.seq,
75 kind,
76 label: clean(label),
77 detail,
78 agentId,
79 status: '',
80 ctx: agentId === undefined ? dbg.messages.length : null,
81 }
82 dbg.events = [...dbg.events, ev as DebugEvent].slice(-MAX_EVENTS)
83 return ev
84}
85
86export const isStopped = (h: Hold) => h.decision === 'stop' || h.decision === 'aborted'
87
88export function pauses(s: DebugSettings, kind: BreakKind, agentId?: string, tool?: string) {
89 if (agentId !== undefined && !s.includeAgents) return false
90 if (dbg.mode === 'step') return true
91 if (!s.breaks.includes(kind)) return false
92 if (tool === undefined) return true
93 return s.tools.includes(tool) ? !s.toolsOff.includes(tool) : s.otherTools
94}
95types/index.d.ts 69 lines1export type BreakKind = 'prompt' | 'request' | 'response' | 'tool' | 'result' | 'turn-end'
2// Observed only: recorded and filtered, never held.
3export type Kind = BreakKind | 'skill' | 'source'
4
5export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | number
6
7export type Usage = { input_tokens?: number; output_tokens?: number } | null
8
9export type Decision = 'continue' | 'step' | 'skip' | 'stop' | 'aborted'
10
11// What each kind of event keeps; the `edited*` and `sent*` fields are set when a hold changed it.
12export type Details = {
13 prompt: { text: string; editedText?: string }
14 request: { model: string; effort?: Effort; messageCount: number; sentModel?: string; sentEffort?: Effort }
15 response: {
16 answer: string
17 toolUses: readonly { name: string; input: unknown }[]
18 stopReason: string | null
19 usage: Usage
20 editedText?: Record<string, string> // by text block index
21 }
22 tool: { tool: string; args: Record<string, unknown>; editedArgs?: Record<string, unknown> }
23 result: { tool: string; isError: boolean; text: string; editedText?: string }
24 'turn-end': { reason: string; durationMs: number; usage: Usage }
25 skill: { skill: string; chars: number }
26 source: { tool: string; url?: string; query?: string; results?: number; ok: boolean }
27}
28
29export type EventOf<K extends Kind> = {
30 id: number
31 kind: K
32 label: string
33 detail: Details[K]
34 agentId?: string
35 status: Decision | 'paused' | ''
36 why?: 'stepping' | 'breakpoint'
37 ctx: number | null // messages in the conversation when it happened; null once that context is gone
38}
39
40export type DebugEvent = { [K in Kind]: EventOf<K> }[Kind]
41
42// A held event's fields as its inputs hold them; read back when the hold is answered.
43export type Edit =
44 | { kind: 'prompt' | 'result'; text: string }
45 | { kind: 'request'; model: string; effort: string }
46 | { kind: 'response'; texts: Record<string, string> }
47 | { kind: 'tool'; args: Record<string, string> }
48 | { kind: 'turn-end' }
49
50export type Hold<E extends Edit = Edit> = { ev: DebugEvent; edit: E; decision: Decision | null }
51
52export type DebugSettings = {
53 breaks: BreakKind[] // the events Play stops on
54 // Tool breakpoints: `tools` less `toolsOff`; `otherTools` speaks for the unlisted (MCP) ones.
55 tools: string[]
56 toolsOff: string[]
57 otherTools: boolean
58 includeAgents: boolean
59 // The events list's filter: kinds not shown, and whether subagents' events are.
60 hidden: Kind[]
61 hideAgents: boolean
62}
63
64declare module 'claude-code' {
65 interface PluginState {
66 'debugger': { settings: DebugSettings }
67 }
68}
69