A WinDbg-style pane that follows windbg-mcp tool calls: disassembly, registers, stack, memory, breakpoints and a command log.

A Claude Code mod that draws a WinDbg-style pane from windbg-mcp tool calls as they happen: disassembly with the stop marked, registers, stack, memory, breakpoints, and a command window showing the WinDbg equivalent of each call. It watches; it never changes a call.
/windbg-pane open the pane
/windbg-pane close close it
/windbg-pane clear forget the session history
/windbg-pane shape report what the last tool result actually looked like
Load it for one session with claude --plugin-dir mods/windbg-pane, or copy the directory to ~/.claude/skills/windbg-pane/, where Claude Code loads it in every session.
A tool call's typed half says a great deal that prose does not, and a pane is the cheapest way to see it: that cr3 changed, that a register read is older than the last step, that a stop is in VTL1 at CPL 0. The folding logic in hooks/model.ts is pure functions over one View, so it is tested without an engine, a target, or a debugger.
Every fixture is a real recording, and that is the point. tests/recorded.ts is a cmd.exe launch breaking on kernelbase!CreateFileW; recorded-kernel.ts a live KDNET attach; recorded-dump.ts an ARM64 kernel crash dump. They were captured through WINDBG_MCP_TRANSCRIPT, not written by hand, because a hand-made fixture agrees with whatever the code already does. Three defects found this way would not have been found otherwise:
cr0/cr2/cr3/cr4/cr8, xcr0, gdtr/idtr/tr and kdr*, and the pane drew none of them — so cr3, the page-table root, was silently dropped;docs/samples/*.dmp are ARM64, so their registers are x0..x28, fp/lr/sp/pc, w0..w30 and bvr/bcr/wvr/wcr — 109 of them, not one matching an x64 name — and the grid came up empty;registers.values[] carrying low/high/ status rather than flat { name, value } rows, and its instruction as a byte array, so none of the ordinary branches saw any of it.All three are the same mistake: a fixed list of names quietly drops whatever it does not enumerate. The pane now picks a register bank per target, and counts anything it leaves out on screen (+n not shown: …), so a missing class shows up as a number instead of vanishing. If you add a target kind, add its bank — and check the count line rather than the grid.
And a fixture only covers the shapes it happens to contain, which is the limit of the paragraph above. Review of this directory found twenty-eight more defects in the same class, none of them reachable from these recordings: an asynchronous run reports moved/running with no stop and names its handle in execution; wait_for_stop nests its stop under stop; run_to_address reports an ending or a timeout through verdict, with stopped_at deliberately absent; goto_position answers with debugger text and no payload at all; clear_breakpoints answers with remaining and carries no breakpoints; debug_batch says what the session holds in after; current_location carries the authoritative position, which nothing but a register read or a stop was setting; session_status is the one tool that says what the server holds, and the only answer to a session a failed open left provisional — including, in execution.stopped, whether a run is still outstanding, which is what a continue_async whose own call timed out leaves behind; and an opener that fails after creating a target puts the only handle that reaches it in error.session_id, where TargetCreated::Yes means the handle is usable rather than abandoned. Every one left stale registers, stack and memory on screen marked current, or hid a session, or drew cleared breakpoints as armed, or sent a working session to recovery. And sk_read_memory is the only guest-memory read an open_sk_capture session has, answering with a type of its own rather than with the live control's — so a capture's reads were folded nowhere at all.
Three of those are worth the extra sentence, because they are not missing fields. remaining is nullable where every other listing is not — an empty array is the success state after clearing everything, and null means the inventory could not be read — so collapsing them reports an unreadable session as a clean one. A read-only batch also reports stopped, so a batch's note distinguishes an inspection from a run — but not its staleness, and reading the final position as proof that nothing ran was wrong: SessionAfterInfo::Stopped carries the instruction pointer and no execution flag, and a resume that loops back to the same breakpoint stops where it started having changed every register. And breakpoints means two different things by tool: on the listing it is the answer, so empty means none, while on a set_breakpoint it is an inspection taken after the mutation and best-effort, so empty means it could not be read — and breakpoint beside it reports what the call actually did, authoritatively and always.
Five rounds in a row landed on this one seam, so the fifth was answered by enumerating instead of waiting for a sixth. Every panel here is built from named fields, and every round found another tool or another field the list did not have — the same mistake as the register banks above, one layer out. A count of what was left out will not fix it this time: that worked for the banks because the server hands over a closed set to count against, and "every tool whose payload this pane should fold" is not one. What is closed is the other direction — the fields — so src/structured.rs was read for every payload carrying one the panels draw, and there are no more. Memory bytes at an address are MemoryRead, SecureKernelRead and LiveMemoryRead and nothing else; stopped_at is StopReport and RunToReport; location_state is CurrentLocation alone, remaining BreakpointsCleared alone; and frames, registers and instructions are matched by shape rather than by tool, so exception_triage's stack folds without ever being named. The audit found one gap of its own, which is the point of running it: a short read_memory carries stopped, saying whether it ran out of time or somebody interrupted it, and that field's own doc says read_size cannot answer the question — the pane drew the shortfall with no reason beside it. Most of the tool surface is named nowhere here, and that is correct rather than a to-do list: this draws five panels and a command window, not a view per tool.
And the audit was still half a rule, because it only covered the fold. The round after it found that every one of those careful model-side decisions was being undone at the draw: a fold that sees movement with no position deliberately keeps the old ip and does not stamp ipAt, and the renderer printed that address as ip anyway, marked its row ▶, and said "ip is elsewhere" about a position nothing had re-read. The register grid already asked a version of the question and the other three sites did not — one rule, applied in one of four places, which is what makes a class fix look finished. ipIsCurrent is now the single predicate and the renderer has no opinion of its own. So: a reading folded carefully is not a reading drawn correctly, and the place to check a staleness rule is every site that paints the thing.
Three more from that round, each a field read instead of inferred. Sessions::current routes an unaddressed call to the newest session that accepts one and no addressed call moves it, so the pane now applies that rule rather than following whichever session was last named — which had it pointing at the older of two open sessions while the server routed elsewhere. SessionEnded.released says whether the worker let go of the target, not whether the session is open, so a worker killed holding one left the session marked unresolved for ever; recovery_required is the field that means unconfirmed, and target_left_running is the teardown fact that is not recoverable afterwards.
The draw-layer audit then closed the rest of it, and the round after found what the audit had dismissed. Five reads are drawn: registers, stack, memory, the position, and the breakpoint inventory. The first four are stamped and gated. The inventory was not, and the audit judged that harmless because an ordinary breakpoint survives a run — which is true and is not the whole set. A one-shot does not: DbgEng removes it when it is hit, so a go after the inventory was read can have deleted an entry the panel went on drawing as armed. bpsAt and bpsMayBeStale close it, and the condition is deliberately the two facts together rather than a plain stale flag, so an inventory with no one-shot in it is never put in doubt by movement alone. The disassembly is the other ungated read and movedAt is correctly not its stamp — code does not change because the target moved — but a patched byte does change it, and patching a byte is what debug_batch is for, so a batch reporting BatchStepInfo::changes now drops the listing rather than redrawing stale bytes as instructions.
**Then the round after that found the one-shot still marked armed in the disassembly, and three rounds of one defect is a design answer rather than a third patch. Each fix had been right and local — the header's ip, then the breakpoint panel — and each time the next drawing site was found by the next review, because every site computed its own view of freshness from the raw fields**. Being correct therefore meant remembering the rule once per panel. drawable(session) now answers all of it in one place — the position, the three stale flags, and the breakpoint addresses split into armed and doubtful — and the renderer reads that and holds no opinion. A new panel that reaches for Session directly is this class of bug returning; put the fact in drawable and draw it from there.
Two reads came with it. A live Secure Kernel stop's instruction is the one it just executed, not the one at the stop — .claude/rules/secure-kernel-live.md says so, and sk_live_step refuses that record fed back as the next instruction — so drawing it as the current listing labelled the previous bytes as the stop and reported the ip as elsewhere, RIP being the instruction after it. And session_status's execution block is matched by its handle rather than read for its flag: a finished run's block is kept "until another run replaces it", so presence says nothing, while a handle the pane has not accounted for is a run it never saw — which is what a continue_async whose own call timed out leaves behind, and stopped: true there still means every earlier read is stale.
And the server's own answer beats a rule the pane cannot finish. Sessions::current routes an unaddressed call to the newest session whose state accepts_default admits — Opening, Attaching, Open, Retired and six of the nine Secure Kernel KD phases. The pane's state is a display string, so any list of the ineligible ones it keeps is a list it has to keep in step, and sk kd releasing is exactly the case such a list misses. So current is a belief rather than a value derived per call: an opener moves it, a session_status row carrying current sets it authoritatively, and otherwise it stays. Deriving it was the previous round's fix and was wrong for the reason this paragraph exists — it put the server's eligibility predicate in the pane, where it cannot be completed.
So when you add a branch here, read the field in src/structured.rs rather than inferring it from a payload in tests/ — the recordings are evidence of what the server did send, never of what it can. The field's doc comment usually states the distinction that matters, and in all three cases above it did.
And an absent field is not the absence of the thing it reports, which is where a decline of mine was too narrow rather than wrong. Review asked earlier for a stopped_at of null to be read as a stop, and that was declined correctly: the field carries skip_serializing_if, so it is absent and never null. What the decline stopped short of is what else produces that absence. Its own doc says "running, gone, or has no thread context", and target_gone is a field of its own precisely because "a target with no thread context also produces" an absent position — so a module-load break answers a successful go with a report that has no position, no verdict, no running and no moved, and looking for the position alone read a run that happened as no movement at all. The tell is the report itself: command and output are the two plain strings every StopReport carries, where ExecutionStarted has the first without the second and RunToReport the second without the first. The pane now marks the reads stale and says the target stopped without saying where.
One of them is the host's contract rather than the server's, and it is the same mistake one layer out. A tool.call hook is handed the MCP record, so an MCP server's own isError rides on ran.result — the very object payloadOf reads the payload out of — while the engine also sets one on the result next resolves to. The hook read the outer flag alone and folded error payloads as successes. answered reads both, and the server's own status as well: Outcome is tagged on it, so status: "error" is there whichever way the host passed the call through, and it is the authority the other two are reporting. Types for the host half are under .claude-plugin/types/, written by the engine when it loads the mod.
And one tool answers with no typed half at all, where the honest reading is a weaker claim rather than a cleverer one. The raw execute hatch declares no output schema, so a g driven through it moves the target and says so only in debugger text. Review asked for the known state-changing command forms to be recognised; that is the thing .claude/rules/execution-waits.md records as impossible — bp X; g, an alias, .if (1) { g } and dx …ExecuteCommand("g") all reach execution without saying so, and the server's own fix was to stop reading the text and ask the engine. The pane cannot ask the engine, so it says the one thing that is true of any raw command: the reads taken before it are no longer known to be current. It marks them stale and claims no position.
A shape read correctly can still be labelled wrongly, which is a different mistake. The two Secure Kernel roots were drawn as trustlet cr3 and sk, and neither label is a thing the reading establishes: cr3 is the root in force at the stop, expectedCr3 the session's provider-hello baseline, and .claude/rules/secure-kernel-live.md says plainly that CR3 is discovered and never asserted — one live sample names no owner, so the labels could have been backwards. They are now cr3 at stop and baseline, which is what was measured.
Reading all three brought out the one tool whose failure carries a payload worth folding: a debug_batch that did not commit, or whose rollback did not finish, is answered as an MCP error wrapped around an Outcome::Ok report (server::batch_settled), so status says ok while the call failed — and its after is the only statement about what the session was left holding. foldBatch therefore runs off the success branch. Nothing else here does: every other failure is an Outcome::Error, whose payload says only what went wrong.
Some fixtures are deliberately absent. tests/recorded-sk.ts and tests/sk.test.tsx hold a lab VM's GUID and live VTL1 addresses from a private run and are gitignored; windbg.test.tsx covers the same code paths with synthetic payloads, so a clone exercises them without the bench.
claude plugin test mods/windbg-pane runs it. claude plugin validate mods/windbg-pane checks the manifest and that every $.state key the module names is in types/index.d.ts.
hooks/register.tsx 516 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { LogEntry, Session } from '../types'
5import { EMPTY, EMPTY_DRAWABLE, answered, begin, cleared, drawable, finish, norm, payloadOf, regHex, shapeOf, wd, windbgTool } from './model'
6
7const PANE = 'windbg'
8const TITLE = 'WinDbg'
9const view = atom({ plugin: 'windbg-pane', key: 'view' } as const, EMPTY)
10
11// Theme keys, so the pane follows the person's Claude Code theme.
12const ACCENT = 'claude'
13const BAD = 'error'
14const WARN = 'warning'
15const GOOD = 'success'
16const MNEMONIC = 'suggestion'
17
18// Register banks, each measured from this server's own `registers` payloads rather than assumed:
19// an x64 target reports `rax`.., an ARM64 one `x0`.., and neither set appears on the other. A name
20// in no bank is still counted on screen, so a bank missing a whole class shows as a number rather
21// than vanishing — which is how `cr3` was found missing on a kernel target, and `x0`.. on ARM64.
22const run = (prefix: string, count: number, from = 0) =>
23 Array.from({ length: count }, (_, i) => prefix + (i + from))
24
25type Bank = { ip: string; main: string[]; debug: string[] }
26
27const X64: Bank = {
28 ip: 'rip',
29 main: [
30 'rax', 'rbx', 'rcx', 'rdx', 'rsi', 'rdi', 'rip', 'rsp', 'rbp',
31 ...run('r', 8, 8), 'efl',
32 'cs', 'ds', 'es', 'fs', 'gs', 'ss',
33 // Kernel only, and `cr3` is the page-table root — the register a kernel session is read for.
34 'cr0', 'cr2', 'cr3', 'cr4', 'cr8', 'xcr0', 'gdtr', 'gdtl', 'idtr', 'idtl', 'tr', 'ldtr',
35 ],
36 debug: ['dr0', 'dr1', 'dr2', 'dr3', 'dr6', 'dr7'],
37}
38
39const A64: Bank = {
40 ip: 'pc',
41 main: [...run('x', 29), 'fp', 'lr', 'sp', 'pc', 'cpsr', 'elr', 'spsr', 'fpsr', 'fpcr'],
42 // Breakpoint/watchpoint value and control registers, and the kernel's own copies.
43 debug: [
44 ...run('bvr', 8), ...run('bcr', 8), ...run('wvr', 2), ...run('wcr', 2),
45 ...run('kbvr', 8), ...run('kbcr', 8), ...run('kwvr', 2), ...run('kwcr', 2),
46 ],
47}
48
49// Live Secure Kernel control reports a fixed twelve and nothing else (`SNAPSHOT_REGISTERS` in
50// `sklive.rs`). `rflags` and `vsm_vp_status` are in neither bank above, so without this the two
51// registers that say what the VP was doing would land in the "not shown" line.
52const SK: Bank = {
53 ip: 'rip',
54 main: ['rip', 'rsp', 'rflags', 'cr3', 'cs', 'vsm_vp_status'],
55 debug: ['dr0', 'dr1', 'dr2', 'dr3', 'dr6', 'dr7'],
56}
57
58/** `w0`..`w30` are 32-bit views of `x0`..`x30`, so they are duplicates rather than omissions. */
59const VIEW = /^w([0-9]|[12][0-9]|30)$/
60
61const bankFor = (map: Map<string, string>): Bank =>
62 map.has('vsm_vp_status') ? SK : map.has('x0') ? A64 : X64
63
64export const register: Register = on => {
65 on('session.start', async ($, e, next) => {
66 await $.command.register({
67 name: 'windbg-pane',
68 description: 'Show the WinDbg pane for windbg-mcp sessions (open, close, clear, shape)',
69 argumentHint: '[open|close|clear|shape]',
70 })
71 return next(e)
72 })
73
74 on('command.run', { command: 'windbg-pane' }, async ($, e) => {
75 const arg = e.args.trim().toLowerCase()
76 if (arg === 'close') {
77 await $.ui.close({ id: PANE })
78 return { text: 'WinDbg pane closed.' }
79 }
80 if (arg === 'clear') {
81 // The command forgets the **history**, which is what it says and what it is for — see
82 // `cleared`, where the reason it is not a reset to `EMPTY` is written down.
83 await update($, view, cleared)
84 return { text: 'WinDbg pane cleared.' }
85 }
86 if (arg === 'shape') {
87 const v = await read($, view)
88 return { text: v.lastShape ? 'Last windbg-mcp result: ' + v.lastShape : 'No windbg-mcp call has answered yet.' }
89 }
90 const opened = await $.ui.open({ id: PANE, title: TITLE })
91 return { text: opened.isPlaced ? 'WinDbg pane opened.' : 'WinDbg pane is open but needs a wider terminal to show.' }
92 })
93
94 // Every windbg-mcp call passes through untouched; the pane only watches it.
95 on('tool.call', async ($, e, next) => {
96 const tool = windbgTool(String(e.tool))
97 if (tool === null) return next(e)
98 const args: Record<string, unknown> = {}
99 for (const [k, val] of Object.entries(e as Record<string, unknown>)) {
100 if (k !== 'tool' && k !== 'tool_use_id' && k !== 'agentId' && k !== 'requestMeta' && k !== 'consent') args[k] = val
101 }
102 let n = 0
103 try {
104 await update($, view, v => {
105 const [nv, k] = begin(v, tool, args)
106 n = k
107 return nv
108 })
109 } catch {
110 // the pane is a convenience; never let it stand in the way of the call
111 }
112 const started = Date.now()
113 const ran = await next(e)
114 try {
115 const data = ran.deny === undefined ? payloadOf(ran.result, ran.text) : null
116 const ok = ran.deny === undefined && answered(ran.result, ran.isError, data)
117 const error = ran.deny !== undefined ? 'denied: ' + ran.deny : typeof ran.text === 'string' ? ran.text : ''
118 let first = false
119 await update($, view, v => {
120 const nv = finish(v, n, tool, args, { ok, data, error, ms: Date.now() - started })
121 first = !v.autoOpened
122 return { ...nv, autoOpened: true, lastShape: ran.deny === undefined ? shapeOf(ran.result, ran.text) : 'denied' }
123 })
124 if (first) $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
125 } catch {
126 // as above
127 }
128 return ran
129 }).catch(($, e, next) => next(e))
130
131 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
132 const { Box, Text } = $.ui.resolve(e)
133 const v = await read($, view)
134 const width = Math.max(32, e.props.bodyColumns)
135 const rows = Math.max(12, e.props.scroll.bodyRows)
136 const s = v.sessions.find(x => x.id === v.current) ?? null
137 // Every freshness question answered once, so no panel below can form its own view of it.
138 // Three rounds of findings were one site disagreeing with another; see `drawable`.
139 const d = s ? drawable(s) : EMPTY_DRAWABLE
140
141 const title = (name: string, note: string, stale = false) => (
142 <Text wrap="truncate-end">
143 <Text bold color={ACCENT}>{name.toUpperCase()}</Text>
144 {note ? <Text dimColor={!stale} color={stale ? WARN : undefined}>{' ' + note}</Text> : null}
145 </Text>
146 )
147
148 // The headline of a live Secure Kernel stop: which VTL and privilege level the VP stopped in,
149 // what stopped it, and whether the calling trustlet's address space was still loaded at the
150 // Secure Kernel entry — the case `allow_transition_cr3` admits and a step must not cross.
151 const skBanner = (x: Session) => {
152 if (!x.sk) return null
153 const k = x.sk
154 const moved = !!k.cr3 && !!k.expectedCr3 && norm(k.cr3) !== norm(k.expectedCr3)
155 return (
156 <Text wrap="truncate-end">
157 <Text bold color={ACCENT}>{'VTL' + k.vtl + ' CPL' + k.cpl}</Text>
158 <Text dimColor>{' vp ' + k.vp + ' vector ' + k.vector + ' '}</Text>
159 <Text bold>{k.reason + (k.slot !== null ? ' slot ' + k.slot : '')}</Text>
160 {x.skPhase === 'running' ? <Text color={WARN}>{' VP running'}</Text> : null}
161 {/*
162 The two roots, named by where each was read rather than by whose address space it is.
163 `cr3` is the root in force at this stop and `expectedCr3` is the session's
164 provider-hello baseline, and `.claude/rules/secure-kernel-live.md` is explicit that one
165 live sample of a VP's CR3 does not establish whose space it is — "CR3 is discovered,
166 never asserted". Labelling them trustlet and sk, as this did, presents an identity the
167 reading cannot support and may have backwards. What the pane can say is that they
168 differ, which is what `allow_transition_cr3` admits.
169 */}
170 {moved ? (
171 <Text>
172 <Text color={WARN}>{' cr3 at stop ' + regHex(k.cr3)}</Text>
173 <Text dimColor>{' baseline ' + regHex(k.expectedCr3)}</Text>
174 </Text>
175 ) : null}
176 </Text>
177 )
178 }
179
180 const header = () => {
181 const others = v.sessions.filter(x => x !== s && x.state === 'open')
182 return (
183 <Box flexDirection="column">
184 <Text wrap="truncate-end">
185 <Text bold color={ACCENT}>{s!.label}</Text>
186 {' ' + s!.kind + ' '}
187 <Text dimColor>{s!.target}</Text>
188 {' '}
189 <Text color={s!.state === 'open' ? GOOD : s!.state === 'unresolved' ? BAD : undefined} dimColor={s!.state === 'closed'}>
190 {s!.gone ? 'target ended' : s!.state}
191 </Text>
192 {others.length ? <Text dimColor>{' also open: ' + others.map(x => x.label + ' ' + x.kind).join(', ')}</Text> : null}
193 </Text>
194 {s!.bugCheck ? (
195 <Text wrap="truncate-end">
196 <Text color={BAD} bold>{'BUGCHECK ' + s!.bugCheck.code + ' ' + s!.bugCheck.name}</Text>
197 {s!.bugCheck.blamed ? <Text dimColor>{' blaming ' + s!.bugCheck.blamed}</Text> : null}
198 </Text>
199 ) : null}
200 {skBanner(s!)}
201 <Text wrap="truncate-end">
202 {d.ip ? (
203 <Text>
204 <Text dimColor>ip </Text>
205 <Text bold>{wd(d.ip)}</Text>
206 <Text dimColor>{' ' + locate(s!, d.ip)}</Text>
207 </Text>
208 ) : s!.ip && !s!.gone ? (
209 // The target moved and said nothing about where it ended up. The last known position
210 // is still worth showing — it is where this session was — but not as `ip`.
211 <Text dimColor>{'moved from ' + wd(s!.ip) + ', position not reported'}</Text>
212 ) : (
213 <Text dimColor>{s!.gone ? 'the target has ended' : 'no position read yet'}</Text>
214 )}
215 </Text>
216 </Box>
217 )
218 }
219
220 const disasm = (w: number, budget: number) => {
221 if (!s!.disasm) return <Box flexDirection="column">{title('Disassembly', '')}<Text dimColor>no disassembly read yet</Text></Box>
222 const all = s!.disasm.rows
223 // Only a position the target is still at can mark a row or claim to be elsewhere, and a row
224 // that is the instruction just *executed* is not a listing the ip should be found in.
225 const history = s!.disasm.executed === true
226 const here = history ? null : d.ip
227 const at = here ? all.findIndex(x => norm(x.address) === here) : -1
228 const from = at > 2 ? Math.min(at - 2, Math.max(0, all.length - budget)) : 0
229 const shown = all.slice(from, from + budget)
230 const wideLoc = w >= 76
231 const wideBytes = w >= 60
232 // A listing read at an explicit address need not contain the stop — reading a driver's
233 // dispatch while stopped in `nt` is the ordinary case. Say where the ip is instead of
234 // flagging it, since the position is known and nothing is wrong.
235 const elsewhere = !!here && at < 0
236 // Live Secure Kernel control answers with the bytes it guarded and no disassembly, so say so
237 // rather than letting an empty mnemonic column read as a failed read.
238 const note = (history
239 ? 'the instruction just executed, no disassembly · '
240 : s!.disasm.bytesOnly ? 'bytes at the stop, no disassembly · ' : '') +
241 'u ' + wd(s!.disasm.start) +
242 (elsewhere ? ' · ip is elsewhere, at ' + (locate(s!, here!) || wd(here)) : '')
243 return (
244 <Box flexDirection="column">
245 {title('Disassembly', note)}
246 {shown.map(x => {
247 const a = norm(x.address)
248 const cur = a === here
249 const sp = x.text.indexOf(' ')
250 const mn = sp < 0 ? x.text : x.text.slice(0, sp)
251 const ops = sp < 0 ? '' : x.text.slice(sp)
252 return (
253 <Text wrap="truncate-end">
254 <Text color={d.doubtful.has(a) ? WARN : BAD}>{d.armed.has(a) ? '●' : d.doubtful.has(a) ? '◌' : ' '}</Text>
255 <Text color={ACCENT} bold>{cur ? '▶ ' : ' '}</Text>
256 <Text dimColor={!cur}>{wd(x.address) + ' '}</Text>
257 {wideLoc ? <Text dimColor>{(x.module ? x.module + '+' + x.rva : '').padEnd(20) + ' '}</Text> : null}
258 {wideBytes ? <Text dimColor>{x.bytes.padEnd(16) + ' '}</Text> : null}
259 <Text color={cur ? ACCENT : MNEMONIC} bold={cur}>{mn}</Text>
260 <Text bold={cur}>{ops}</Text>
261 </Text>
262 )
263 })}
264 </Box>
265 )
266 }
267
268 const registers = (w: number) => {
269 if (!s!.regs) return <Box flexDirection="column">{title('Registers', '')}<Text dimColor>no registers read yet</Text></Box>
270 const stale = d.regsStale
271 const map = new Map(s!.regs.map(r => [r.name, r.value]))
272 const prev = new Map((s!.prevRegs ?? []).map(r => [r.name, r.value]))
273 const bank = bankFor(map)
274 const named = bank.main.filter(x => map.has(x))
275 // dr6/dr7 read non-zero on an ordinary kernel target, so this is "armed", not "interesting".
276 const debug = bank.debug.filter(x => map.has(x) && norm(map.get(x)) !== '0')
277 const drawn = [...named, ...debug]
278 const rest = [...map.keys()].filter(x => !drawn.includes(x))
279 const views = rest.filter(x => VIEW.test(x))
280 const others = rest.filter(x => !VIEW.test(x))
281 // Sized from the longest name actually drawn rather than from a constant: `vsm_vp_status` is
282 // thirteen characters, and a fixed 22-column cell truncated the line it shared.
283 const nameW = Math.max(3, ...drawn.map(x => x.length))
284 const perLine = Math.max(1, Math.floor((w + 1) / (nameW + 18)))
285 const lines: string[][] = []
286 for (const name of drawn) {
287 const last = lines[lines.length - 1]
288 if (!last || last.length >= perLine) lines.push([name])
289 else last.push(name)
290 }
291 return (
292 <Box flexDirection="column">
293 {title('Registers', stale ? 'read before the target last moved' : 'current', stale)}
294 {lines.map(line => (
295 <Text wrap="truncate-end">
296 {line.map(name => {
297 const live = name === bank.ip && !!d.ip && s!.ipAt > s!.regsAt
298 const value = live ? d.ip! : map.get(name) ?? ''
299 const before = prev.get(name)
300 const changed = !live && before !== undefined && norm(before) !== norm(value)
301 const reg = s!.regs!.find(r => r.name === name)
302 return (
303 <Text>
304 <Text dimColor>{name.padStart(nameW) + '='}</Text>
305 <Text
306 color={reg?.failed ? BAD : live ? ACCENT : changed ? BAD : undefined}
307 bold={live || changed}
308 dimColor={stale && !live}
309 >
310 {reg?.failed ? '?'.repeat(16) : regHex(value)}
311 </Text>
312 {' '}
313 </Text>
314 )
315 })}
316 </Text>
317 ))}
318 {/* A high half is kept by the server rather than discarded after validation — `cs` carries
319 its descriptor there — so it is shown rather than silently halved. */}
320 {drawn.filter(x => s!.regs!.find(r => r.name === x)?.high).map(x => (
321 <Text wrap="truncate-end" dimColor>
322 {x + '.high=' + regHex(s!.regs!.find(r => r.name === x)!.high)}
323 </Text>
324 ))}
325 {others.length ? (
326 <Text wrap="truncate-end" dimColor>{'+' + others.length + ' not shown: ' + others.join(' ')}</Text>
327 ) : null}
328 {views.length ? (
329 <Text wrap="truncate-end" dimColor>{views.length + ' 32-bit views of the above hidden'}</Text>
330 ) : null}
331 </Box>
332 )
333 }
334
335 const stack = (w: number, budget: number) => {
336 if (!s!.frames) return <Box flexDirection="column">{title('Stack', '')}<Text dimColor>no stack read yet</Text></Box>
337 const stale = d.framesStale
338 const more = s!.frames.length > budget || s!.framesTruncated
339 return (
340 <Box flexDirection="column">
341 {title('Stack', stale ? 'read before the target last moved' : s!.frames.length + ' frames' + (s!.framesTruncated ? ', more not read' : ''), stale)}
342 {s!.frames.slice(0, budget).map(f => {
343 const disp = f.displacement && norm(f.displacement) !== '0' ? '+' + f.displacement : ''
344 return (
345 <Text wrap="truncate-end" dimColor={stale}>
346 <Text dimColor>{String(f.index).padStart(2, '0') + ' '}</Text>
347 {(f.symbol || (f.module ? f.module + '+' + f.rva : wd(f.address))) + disp}
348 {w >= 60 ? <Text dimColor>{' ' + (f.module ? f.module + '+' + f.rva : wd(f.address))}</Text> : null}
349 </Text>
350 )
351 })}
352 {more && s!.frames.length > budget ? <Text dimColor>{' ' + (s!.frames.length - budget) + ' more'}</Text> : null}
353 </Box>
354 )
355 }
356
357 const memory = (w: number, budget: number) => {
358 if (!s!.mem) return <Box flexDirection="column">{title('Memory', '')}<Text dimColor>no memory read yet</Text></Box>
359 const stale = d.memStale
360 const per = w >= 84 ? 16 : 8
361 const hex = s!.mem.hex
362 const base = BigInt('0x' + (norm(s!.mem.address) ?? '0'))
363 const lines: string[] = []
364 for (let off = 0; off * 2 < hex.length && lines.length < budget; off += per) {
365 let h = ''
366 let asc = ''
367 for (let j = 0; j < per && (off + j) * 2 < hex.length; j++) {
368 const b = parseInt(hex.slice((off + j) * 2, (off + j) * 2 + 2), 16)
369 h += hex.slice((off + j) * 2, (off + j) * 2 + 2) + (j === 7 && per === 16 ? '-' : ' ')
370 asc += b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '.'
371 }
372 lines.push(wd((base + BigInt(off)).toString(16)) + ' ' + h.padEnd(per * 3) + ' ' + asc)
373 }
374 return (
375 <Box flexDirection="column">
376 {title('Memory', (stale ? 'read before the target last moved · ' : '') + 'db ' + wd(s!.mem.address) +
377 ' · ' + s!.mem.size + ' bytes' + (s!.mem.gpa ? ' · gpa ' + wd(s!.mem.gpa) : ''), stale)}
378 {lines.map(l => <Text wrap="truncate-end" dimColor={stale}>{l}</Text>)}
379 {/* An x64 machine frame read as fields rather than five identical-looking quadwords.
380 `cs`/`ss` naming ring 3 at a CPL0 stop is the evidence the caller was user mode, so it
381 is spelled out instead of left to be decoded from the hex above. */}
382 {s!.mem.frame ? (
383 <Box flexDirection="column" marginTop={1}>
384 <Text wrap="truncate-end">
385 <Text color={ACCENT}>{'machine frame'}</Text>
386 <Text dimColor>{' interpreted · '}</Text>
387 <Text bold color={s!.mem.frame.ring === 3 ? WARN : undefined}>
388 {'ring ' + s!.mem.frame.ring + (s!.mem.frame.ring === 3 ? ' (user mode)' : '')}
389 </Text>
390 </Text>
391 {([
392 ['+00', 'rip', s!.mem.frame.rip],
393 ['+08', 'cs', s!.mem.frame.cs],
394 ['+10', 'rflags', s!.mem.frame.rflags],
395 ['+18', 'rsp', s!.mem.frame.rsp],
396 ['+20', 'ss', s!.mem.frame.ss],
397 ] as const).map(([off, name, value]) => (
398 <Text wrap="truncate-end">
399 <Text dimColor>{off + ' '}</Text>
400 <Text>{name.padEnd(7)}</Text>
401 <Text bold>{name === 'cs' || name === 'ss' ? value : wd(value)}</Text>
402 <Text dimColor>
403 {name === 'cs' || name === 'ss'
404 ? ' ring ' + (parseInt(value, 16) & 3)
405 : name === 'rflags'
406 ? ''
407 : BigInt(value) < 1n << 47n ? ' user address' : ' kernel address'}
408 </Text>
409 </Text>
410 ))}
411 </Box>
412 ) : null}
413 </Box>
414 )
415 }
416
417 const breakpoints = (budget: number) => {
418 if (!s!.bps) return null
419 // A one-shot the target may have hit since this was read is no longer known to be armed, and
420 // only the one-shots are in doubt — an ordinary breakpoint survives a run.
421 const used = d.bpsStale
422 return (
423 <Box flexDirection="column">
424 {title('Breakpoints', used ? 'a one-shot may have been hit since this was read' : s!.bps.length + ' set', used)}
425 {s!.bps.slice(0, budget).map(b => (
426 <Text wrap="truncate-end" dimColor={!b.enabled || (used && b.oneShot)}>
427 <Text color={used && b.oneShot ? WARN : BAD}>{'● '}</Text>
428 {String(b.id).padEnd(3) + (b.enabled ? 'e ' : 'd ') + (b.address ? wd(b.address) : 'deferred') + ' ' + b.expression + (b.oneShot ? ' /1' : '')}
429 </Text>
430 ))}
431 </Box>
432 )
433 }
434
435 const log = (budget: number) => {
436 const entries = v.log.slice(-Math.max(1, budget))
437 return (
438 <Box flexDirection="column">
439 {title('Command', v.log.length + ' calls')}
440 {entries.map(x => logLine(x))}
441 </Box>
442 )
443 }
444
445 const logLine = (x: LogEntry) => {
446 const label = x.session ? v.sessions.find(o => o.id === x.session)?.label ?? '' : ''
447 return (
448 <Text wrap="truncate-end">
449 <Text color={ACCENT}>{(v.sessions.length > 1 && label ? label + ' ' : '') + '> '}</Text>
450 <Text bold={x.isWinDbg} color={x.isWinDbg ? undefined : MNEMONIC}>{x.command}</Text>
451 <Text dimColor>{' ' + x.tool + ' '}</Text>
452 <Text color={x.status === 'error' ? BAD : x.status === 'running' ? WARN : GOOD}>
453 {x.status === 'running' ? 'running' : x.status === 'error' ? 'failed' : 'ok'}
454 </Text>
455 {x.note ? <Text dimColor={x.status !== 'error'} color={x.status === 'error' ? BAD : undefined}>{' — ' + x.note}</Text> : null}
456 </Text>
457 )
458 }
459
460 if (!s) {
461 return (
462 <Box flexDirection="column">
463 <Text bold color={ACCENT}>WinDbg</Text>
464 <Text dimColor>
465 {v.log.length ? 'No session is open.' : 'Waiting for a windbg-mcp call. Open a target (launch, open_dump, attach_kernel, ...) and this pane follows it.'}
466 </Text>
467 {v.log.length ? log(rows - 3) : null}
468 </Box>
469 )
470 }
471
472 if (width >= 110) {
473 const left = Math.floor(width * 0.56)
474 const right = width - left - 2
475 const disBudget = Math.max(6, Math.floor((rows - 4) * 0.55))
476 return (
477 <Box flexDirection="column">
478 {header()}
479 <Box flexDirection="row" columnGap={2} marginTop={1}>
480 <Box flexDirection="column" width={left} rowGap={1}>
481 {disasm(left, disBudget)}
482 {log(Math.max(3, rows - disBudget - 6))}
483 </Box>
484 <Box flexDirection="column" width={right} rowGap={1}>
485 {registers(right)}
486 {stack(right, 8)}
487 {memory(right, 6)}
488 {breakpoints(4)}
489 </Box>
490 </Box>
491 </Box>
492 )
493 }
494
495 return (
496 <Box flexDirection="column" rowGap={1}>
497 {header()}
498 {disasm(width, 7)}
499 {registers(width)}
500 {stack(width, 5)}
501 {memory(width, 4)}
502 {breakpoints(3)}
503 {log(6)}
504 </Box>
505 )
506 })
507}
508
509function locate(s: Session, ip: string): string {
510 const row = s.disasm?.rows.find(x => norm(x.address) === ip)
511 if (row && row.module) return row.module + '+' + row.rva
512 const top = s.frames?.[0]
513 if (top && norm(top.address) === ip) return top.symbol || top.module + '+' + top.rva
514 return ''
515}
516hooks/model.ts 1012 lines1// What the pane knows, folded from windbg-mcp tool calls. Pure functions over `View`, so the
2// tests can drive them with payloads recorded from a real server.
3
4import type { Bp, Frame, Ins, LogEntry, Reg, Session, SkStop, View } from '../types'
5
6export const EMPTY: View = { n: 0, current: null, sessions: [], log: [], autoOpened: false, lastShape: '' }
7
8const LOG_KEEP = 80
9const DIS_KEEP = 64
10const MEM_KEEP = 512
11
12export const OPEN =
13 /^(launch|open_dump|open_trace|open_sk_capture|open_sk_live_control|open_sk_kd|attach_kernel|attach_kernel_local|attach_process)$/
14export const RUN =
15 /^(go|step_into|step_over|reverse_go|step_back|step_over_back|run_to_address|continue_async|wait_for_stop|goto_position)$/
16/** The live Secure Kernel calls that put the VP back to running and answer with an epoch alone. */
17export const SK_RESUME = /^(sk_live_arm|sk_live_step|sk_live_continue)$/
18
19/** The windbg-mcp tool a Claude Code tool name calls, or null: any MCP server whose name says windbg. */
20export function windbgTool(name: string): string | null {
21 if (!name.startsWith('mcp__')) return null
22 const cut = name.lastIndexOf('__')
23 if (cut <= 5) return null
24 return /windbg/i.test(name.slice(5, cut)) ? name.slice(cut + 2) : null
25}
26
27/** An address as lower-case hex with no prefix, padding or WinDbg backtick; null if it is not one. */
28export function norm(a: unknown): string | null {
29 if (typeof a !== 'string' && typeof a !== 'number') return null
30 let s = String(a).trim().toLowerCase().replace(/`/g, '')
31 if (s.startsWith('0x')) s = s.slice(2)
32 if (!/^[0-9a-f]+$/.test(s)) return null
33 return s.replace(/^0+(?=.)/, '')
34}
35
36/** WinDbg's 64-bit spelling: sixteen digits, a backtick between the halves. */
37export function wd(a: unknown): string {
38 const n = norm(a)
39 if (n === null) return String(a)
40 if (n.length > 16) return n
41 const p = n.padStart(16, '0')
42 return p.slice(0, 8) + '`' + p.slice(8)
43}
44
45export function regHex(v: unknown): string {
46 const n = norm(v)
47 if (n === null) return String(v)
48 return n.length <= 16 ? n.padStart(16, '0') : n
49}
50
51/** The WinDbg command a call stands for, where there is one; null means show the tool call itself. */
52export function commandFor(tool: string, args: Record<string, unknown>): string | null {
53 const s = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : undefined)
54 const n = (k: string) => (typeof args[k] === 'number' ? (args[k] as number) : undefined)
55 switch (tool) {
56 case 'go': return 'g'
57 case 'step_over': return 'p'
58 case 'step_into': return 't'
59 case 'reverse_go': return 'g-'
60 case 'step_back': return 't-'
61 case 'step_over_back': return 'p-'
62 case 'registers': return 'r'
63 case 'backtrace': return 'k'
64 case 'modules': return 'lm'
65 case 'threads': return '~'
66 case 'breakpoints': return 'bl'
67 case 'disassemble': return 'u' + (s('address') ? ' ' + s('address') : '') + (n('count') ? ' L0n' + n('count') : '')
68 case 'read_memory':
69 // The captured VTL1 read has no WinDbg spelling of its own; `db` is what it does.
70 case 'sk_read_memory': return s('address') && n('size') ? 'db ' + s('address') + ' L0n' + n('size') : null
71 case 'set_breakpoint':
72 if (args.watch || !s('expression')) return null
73 return 'bp' + (args.one_shot ? ' /1' : '') + ' ' + s('expression')
74 case 'dx': return s('expression') ? 'dx ' + s('expression') : null
75 case 'execute': return s('command') ?? null
76 // Live Secure Kernel control. `natural` mode arms a debug register, so `ba e1` is the honest
77 // equivalent; `sk_live_wait` is the debugger blocking for the stop and has no WinDbg spelling.
78 case 'sk_live_arm': return s('address') ? 'ba e1 ' + s('address') : null
79 case 'sk_live_step': return 't'
80 case 'sk_live_continue': return 'g'
81 case 'sk_live_registers': return 'r'
82 case 'sk_live_read_memory':
83 return s('address') ? 'db ' + s('address') + (n('size') ? ' L0n' + n('size') : '') : null
84 default: return null
85 }
86}
87
88export function argsText(args: Record<string, unknown>): string {
89 const copy: Record<string, unknown> = { ...args }
90 delete copy.session_id
91 const t = JSON.stringify(copy)
92 return t === '{}' ? '' : t
93}
94
95type Obj = Record<string, unknown>
96const obj = (v: unknown): Obj | null => (v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Obj) : null)
97
98function blocksText(blocks: unknown[]): string {
99 return blocks
100 .map(b => (obj(b) && typeof (b as Obj).text === 'string' ? ((b as Obj).text as string) : ''))
101 .join('')
102}
103
104function parseObj(t: string): Obj | null {
105 const s = t.trim()
106 if (!s.startsWith('{')) return null
107 try {
108 return obj(JSON.parse(s))
109 } catch {
110 return null
111 }
112}
113
114/**
115 * The typed half of a windbg-mcp answer, from whichever shape the call result carries it in:
116 * the MCP result's `structuredContent`, its text blocks holding the JSON, or the text the model read.
117 */
118export function payloadOf(result: unknown, text: unknown): Obj | null {
119 const r = obj(result)
120 if (r) {
121 const sc = obj(r.structuredContent)
122 if (sc) return sc
123 if (Array.isArray(r.content)) {
124 const p = parseObj(blocksText(r.content))
125 if (p) return p
126 }
127 if (typeof r.status === 'string') return r
128 }
129 if (Array.isArray(result)) {
130 const p = parseObj(blocksText(result))
131 if (p) return p
132 }
133 if (typeof result === 'string') {
134 const p = parseObj(result)
135 if (p) return p
136 }
137 return typeof text === 'string' ? parseObj(text) : null
138}
139
140/**
141 * Whether a call answered, from all three places a failure can arrive in.
142 *
143 * The host sets `isError` on the result `next` resolves to when a tool "answered an error", and an
144 * MCP server's own `isError` rides on the nested record beside the payload — the same record
145 * `payloadOf` reads — so which of the two carries it depends on how the host passed the call
146 * through. Reading only the outer one treated every error as a success: an error payload folded
147 * into the session's registers, stack and position, and an opener's failure could still mint a
148 * session. And the server says so itself without either: `Outcome` is tagged on `status`
149 * (`src/structured.rs`), so `status: "error"` is the one statement that is there whichever way the
150 * host conveyed it, and it is the authority the other two are reporting.
151 */
152export function answered(result: unknown, isError: unknown, data: Obj | null): boolean {
153 if (isError === true) return false
154 const r = obj(result)
155 if (r && r.isError === true) return false
156 return !(data && data.status === 'error')
157}
158
159/** A one-line description of a result's shape, for checking what the host hands the hook. */
160export function shapeOf(result: unknown, text: unknown): string {
161 const r = obj(result)
162 const what = r
163 ? 'object {' + Object.keys(r).join(', ') + '}'
164 : Array.isArray(result) ? 'array of ' + result.length : typeof result
165 return 'result: ' + what + '; text: ' + (typeof text === 'string' ? text.length + ' chars' : typeof text)
166}
167
168function freshSession(v: View, id: string): Session {
169 return {
170 id, label: 's' + (v.sessions.length + 1), kind: '', target: '', state: 'open',
171 regs: null, prevRegs: null, regsAt: -1, ip: null, ipAt: -1, movedAt: -1,
172 disasm: null, frames: null, framesTruncated: false, framesAt: -1, mem: null, memAt: -1, bps: null, bpsAt: -1, gone: false, execSeen: '',
173 bugCheck: null, sk: null, skPhase: '',
174 }
175}
176
177/**
178 * The registers of a live Secure Kernel stop, which arrive as `registers.values[]` carrying
179 * `low`/`high`/`status` rather than as the flat `{ name, value }` rows every other target reports.
180 * The server keeps the high half and the status on purpose, so neither is dropped here.
181 */
182function skRegisters(d: Obj): Reg[] | null {
183 const rows = obj(d.registers)?.values
184 if (!Array.isArray(rows)) return null
185 return (rows as unknown[]).flatMap(x => {
186 const o = obj(x)
187 if (!o || typeof o.name !== 'string') return []
188 const high = norm(o.high)
189 const reg: Reg = { name: o.name, value: str(o.low) }
190 if (high !== null && high !== '0') reg.high = str(o.high)
191 if (o.status !== undefined && norm(o.status) !== '0') reg.failed = true
192 return [reg]
193 })
194}
195
196/** The bytes at a stop, which come as a byte array rather than as a hex string. */
197function insBytes(v: unknown): string {
198 if (!Array.isArray(v)) return str(v)
199 return (v as unknown[])
200 .map(b => (typeof b === 'number' ? b.toString(16).padStart(2, '0') : ''))
201 .join('')
202}
203
204/**
205 * An x64 machine frame recognised in a memory read: `rip, cs, rflags, rsp, ss` at eight-byte
206 * stride — what `iretq` pops, and what the Secure Kernel's syscall prologue builds by hand. As hex
207 * it is five indistinguishable quadwords; as fields, `cs` and `ss` say which ring the caller was
208 * in, and at a CPL0 stop that is the whole point.
209 *
210 * Recognised rather than assumed, because most reads are not frames: selectors occupy the low 16
211 * bits with the rest zero, `rflags` bit 1 is always set and its top half reserved, and `rip`/`rsp`
212 * are non-zero. A read failing any of those gets no interpretation.
213 */
214export function readFrame(hex: string): NonNullable<Session['mem']>['frame'] {
215 if (hex.length < 80) return undefined
216 const q: bigint[] = []
217 for (let i = 0; i < 5; i++) {
218 let v = 0n
219 for (let j = 7; j >= 0; j--) {
220 const byte = hex.slice((i * 8 + j) * 2, (i * 8 + j) * 2 + 2)
221 if (!/^[0-9a-fA-F]{2}$/.test(byte)) return undefined
222 v = (v << 8n) | BigInt(parseInt(byte, 16))
223 }
224 q.push(v)
225 }
226 const [rip, cs, rflags, rsp, ss] = q as [bigint, bigint, bigint, bigint, bigint]
227 const selector = (v: bigint) => v > 0n && v <= 0xffffn
228 if (!selector(cs) || !selector(ss)) return undefined
229 if ((rflags & 2n) === 0n || rflags > 0x3fffffn) return undefined
230 if (rip === 0n || rsp === 0n) return undefined
231 const hx = (v: bigint) => '0x' + v.toString(16).padStart(16, '0')
232 return {
233 rip: hx(rip),
234 cs: '0x' + cs.toString(16).padStart(4, '0'),
235 rflags: hx(rflags),
236 rsp: hx(rsp),
237 ss: '0x' + ss.toString(16).padStart(4, '0'),
238 ring: Number(cs & 3n),
239 }
240}
241
242/** Where a live Secure Kernel stop happened, and whether the trustlet's CR3 was still loaded. */
243function skStopOf(d: Obj, regs: Reg[] | null): SkStop | null {
244 const ev = obj(d.event)
245 const t = obj(d.target)
246 if (!ev || !t) return null
247 const reason = obj(ev.reason)
248 const num = (v: unknown): number => (typeof v === 'number' ? v : -1)
249 const cr3 = regs?.find(r => r.name === 'cr3')?.value ?? ''
250 return {
251 vtl: num(ev.vtl), cpl: num(ev.cpl), vp: num(ev.vp), vector: num(ev.vector),
252 reason: str(reason?.reason, str(ev.reason)),
253 slot: typeof reason?.slot === 'number' ? reason.slot : null,
254 partition: str(t.partition_id), expectedCr3: str(t.expected_cr3), cr3,
255 epoch: str(d.epoch),
256 }
257}
258
259/** A bug check from an opener's `summary` or from `crash_triage`, with whatever it blames. */
260function bugCheckOf(d: Obj): Session['bugCheck'] {
261 const bc = obj(d.bug_check) ?? obj(obj(d.summary)?.bug_check)
262 if (!bc) return null
263 const blamed = obj(d.faulting_frame)
264 const analysis = obj(d.analysis)
265 return {
266 code: str(bc.code),
267 name: str(bc.name),
268 blamed: blamed
269 ? str(blamed.module) + '+' + str(blamed.rva)
270 : str(analysis?.module_name),
271 }
272}
273
274/** A call arrived: log it as running. Returns the new view and the call's number. */
275/**
276 * What `/windbg-pane clear` leaves: the history forgotten, the sessions kept.
277 *
278 * Here rather than in the hook so a test can reach it, which is the whole reason the folding lives
279 * in this file. Resetting to `EMPTY` also discarded the known sessions and `current` while the
280 * *server's* sessions stayed open — and an implicit call has no session to be folded into then,
281 * `session_status` included, because a listing deliberately does not mint one. The pane quietly
282 * stopped following anything until the next opener.
283 */
284export function cleared(v: View): View {
285 return { ...v, log: [], lastShape: '' }
286}
287
288export function begin(v: View, tool: string, args: Record<string, unknown>): [View, number] {
289 const n = v.n + 1
290 const wdc = commandFor(tool, args)
291 const sid = typeof args.session_id === 'string' ? args.session_id : OPEN.test(tool) ? null : v.current
292 const entry: LogEntry = {
293 n, tool, command: wdc ?? tool + (argsText(args) ? ' ' + argsText(args) : ''), isWinDbg: wdc !== null,
294 session: sid, status: 'running', note: '', ms: 0,
295 }
296 return [{ ...v, n, log: [...v.log, entry].slice(-LOG_KEEP) }, n]
297}
298
299export type Outcome = { ok: boolean; data: Obj | null; error: string; ms: number }
300
301/**
302 * Whether `s.ip` is where the target is **now**, rather than where it was before the last movement.
303 *
304 * Every fold that sees movement with no position deliberately keeps the old `ip` and does not stamp
305 * `ipAt` — a text-only `goto_position`, a `run_to_address` timeout, a stop with no thread context,
306 * an outstanding asynchronous run, a raw `execute`. That care was being discarded at the draw: the
307 * header printed the old address as `ip`, the disassembly marked it `▶`, and the panel note read
308 * "ip is elsewhere" about a position nothing had re-read. The register grid already asked a version
309 * of this question and the other three sites did not, which is how one rule came to be half
310 * applied. Exported so the renderer and the tests share the one predicate.
311 */
312export function ipIsCurrent(s: Session): boolean {
313 return !!s.ip && !s.gone && s.ipAt >= s.movedAt
314}
315
316/**
317 * Whether the breakpoint inventory may have been consumed since it was read.
318 *
319 * The other four reads go stale on any movement; this one does not — an ordinary breakpoint
320 * survives a `go`, and greying the panel on every step would be wrong. A **one-shot** does not:
321 * DbgEng removes it when it is hit, so a run after the inventory was read can have deleted an
322 * entry the pane would otherwise go on drawing as armed. So the condition is the two facts
323 * together, which is why this is a predicate and not a `stale` flag like the others.
324 */
325export function bpsMayBeStale(s: Session): boolean {
326 return !!s.bps && s.movedAt > s.bpsAt && s.bps.some(b => b.oneShot)
327}
328
329/** Nothing may be asserted about no session. */
330export const EMPTY_DRAWABLE: Drawable = {
331 ip: null, regsStale: false, framesStale: false, memStale: false, bpsStale: false,
332 armed: new Set(), doubtful: new Set(),
333}
334
335/** What a renderer may assert about a session, decided once. */
336export type Drawable = {
337 /** The position, or null where the target has moved since it was read. */
338 ip: string | null
339 regsStale: boolean
340 framesStale: boolean
341 memStale: boolean
342 /** Whether the inventory may have lost a one-shot since it was read. */
343 bpsStale: boolean
344 /** Addresses that can be drawn as armed. */
345 armed: Set<string>
346 /** One-shot addresses that execution may already have consumed. */
347 doubtful: Set<string>
348}
349
350/**
351 * Every freshness question about a session, answered in one place.
352 *
353 * **This exists because three consecutive review rounds were the same defect**: a staleness rule
354 * applied at one drawing site and not at another. The header asserted an `ip` the folds had
355 * deliberately not re-stamped; then the breakpoint panel hedged a consumed one-shot while the
356 * disassembly went on marking it armed. Each fix was correct and local, and the next site was
357 * found by the next round, because every site was computing its own view from the raw fields —
358 * so being right required remembering the rule four times.
359 *
360 * The renderer now asks this and has no opinion of its own. A new panel that reads `Session`
361 * directly is the bug coming back; add the fact here and draw it from here.
362 */
363export function drawable(s: Session): Drawable {
364 const bpsStale = bpsMayBeStale(s)
365 const armed = new Set<string>()
366 const doubtful = new Set<string>()
367 for (const b of s.bps ?? []) {
368 if (!b.enabled) continue
369 const a = norm(b.address)
370 if (!a) continue
371 // Only the one-shots are in doubt: an ordinary breakpoint survives a run.
372 if (bpsStale && b.oneShot) doubtful.add(a)
373 else armed.add(a)
374 }
375 return {
376 ip: ipIsCurrent(s) ? s.ip : null,
377 regsStale: s.movedAt > s.regsAt,
378 framesStale: s.movedAt > s.framesAt,
379 memStale: s.movedAt > s.memAt,
380 bpsStale,
381 armed,
382 doubtful,
383 }
384}
385
386/**
387 * The states `SessionState::accepts_default` refuses that this pane can actually name.
388 *
389 * `accepts_default` is `accepts_handle() || Retired`, and `accepts_handle` admits `Opening`,
390 * `Attaching`, `Open` and six of the Secure Kernel KD phases — so `Failed`, `KernelUnresolved`,
391 * `LiveControlUnresolved`, `Closed` and the remaining KD phases are all refused. The pane sees
392 * display strings rather than that enum, so this names the four it can recognise and **cannot** be
393 * complete: the KD phases in particular are a list it would have to keep in step. That is why the
394 * server's own answer wins wherever it has been read — see `routed` below.
395 */
396const NOT_ROUTABLE = new Set(['closed', 'failed', 'unresolved', 'unknown to the server'])
397
398/**
399 * The newest session that could take an unaddressed call: `Sessions::current`'s `rev().find(…)`,
400 * as far as the pane can tell. Used only as the fallback when the believed session has stopped
401 * being eligible and no status call has said which one took over.
402 */
403function newestOpen(sessions: Session[]): string | null {
404 for (let i = sessions.length - 1; i >= 0; i--) {
405 if (!NOT_ROUTABLE.has(sessions[i]!.state)) return sessions[i]!.id
406 }
407 return null
408}
409
410function where(s: Session, ip: string | null): string {
411 if (!ip) return ''
412 const row = s.disasm?.rows.find(x => norm(x.address) === ip)
413 return row && row.module ? row.module + '+' + row.rva : wd(ip)
414}
415
416const str = (v: unknown, d = ''): string => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : d)
417
418/** The breakpoint rows of any listing that carries them — `breakpoints`, or a clear's `remaining`. */
419function readBps(rows: unknown[]): Bp[] {
420 return rows.flatMap(x => {
421 const o = obj(x)
422 return o
423 ? [{
424 id: typeof o.id === 'number' ? o.id : 0, address: str(o.address), expression: str(o.expression),
425 enabled: o.enabled !== false, oneShot: o.one_shot === true,
426 } as Bp]
427 : []
428 })
429}
430
431/**
432 * A `session_status` row's state, which is tagged on `state` and carries its own reason.
433 *
434 * `attaching` also says whether it has been waiting longer than a healthy open ever takes, which
435 * is the fact that separates a KD link coming up from a guest that was never booted in debug mode
436 * — the state alone cannot. Both unresolved variants read as `unresolved`, which is what the pane
437 * already calls them: the pane says what is refused, and the reason is in the log line.
438 */
439function sessionState(st: Obj | null): string {
440 const tag = st ? str(st.state) : ''
441 switch (tag) {
442 case 'attaching':
443 return st?.overdue === true ? 'attaching, overdue' : 'attaching'
444 case 'kernel_unresolved':
445 case 'live_control_unresolved':
446 return 'unresolved'
447 case 'secure_kernel_kd':
448 return ('sk kd ' + str(st?.phase)).trim()
449 default:
450 return tag
451 }
452}
453
454/**
455 * What a `debug_batch` left the session holding, from its `after` report.
456 *
457 * A batch can resume, step or release the target, and `after` is the only thing that says where
458 * that left the session — the step list cannot. Without reading it a batch holding a `resume` or a
459 * `run_to` left the old registers, stack, memory, position and target state on screen marked
460 * current.
461 *
462 * It is folded off the success branch because **a batch that failed still carries this report**: a
463 * batch that did not commit, or whose rollback did not finish, is answered as an MCP error wrapped
464 * around an `Outcome::Ok` report (`server::batch_settled`), and `after` is then the only statement
465 * about what the session holds. That is the case the pane least wants to skip — `uncertain` is
466 * precisely the answer that says the earlier reads can no longer be trusted.
467 */
468/**
469 * Forget the two reads that **movement does not stale**, because something may have written.
470 *
471 * `movedAt` covers the registers, the stack, the memory and the position. It cannot cover these
472 * two: code does not change because the target moved, and an ordinary breakpoint survives a run —
473 * so greying either on movement would be wrong, and both go stale on a *write* instead. Null is
474 * the right state rather than an old timestamp: it hides the panel instead of asserting a count or
475 * redrawing bytes as instructions, which is what "the pane does not know" has to look like.
476 */
477function forgetInventories(s: Session, n: number): void {
478 s.disasm = null
479 s.bps = null
480 s.bpsAt = n
481}
482
483/**
484 * What a batch's steps say they changed, from `BatchStepInfo::changes` in both blocks.
485 *
486 * Read because it is the only thing that invalidates the **disassembly**, and the stale marks the
487 * other panels carry cannot stand in for it: `movedAt` is movement, and movement does not change
488 * code — a listing greyed on every step would be wrong. A *patched byte* does change it, and
489 * patching a byte is what `debug_batch` is for. The field is the executor's own record, "recorded
490 * whether or not the step then succeeded", since a command that errors may already have written;
491 * the `always` block counts too, because a rollback writes.
492 */
493function batchChanges(d: Obj): string[] {
494 const blocks = [d.steps, d.always].flatMap(b => (Array.isArray(b) ? (b as unknown[]) : []))
495 return blocks.flatMap(x => {
496 const o = obj(x)
497 const c = o ? str(o.changes) : ''
498 return c ? [c] : []
499 })
500}
501
502function foldBatch(s: Session, d: Obj, n: number): string {
503 // Before the `after` guard: a batch can report what it wrote and fail to probe the session, and
504 // what it wrote is true either way.
505 const changed = batchChanges(d)
506 // Dropped rather than stamped. A stamp would say the listing is old; this says the pane does not
507 // know what is there any more, which is the honest claim once a step has written.
508 //
509 // **Both inventories, and deliberately without reading which kind of change it was.** `changes`
510 // is a fixed vocabulary — "memory", "registers", "breakpoints", "the debug target" and two more —
511 // so matching `breakpoints` in it would work and would still be wrong: `batch::mutation`'s own
512 // doc calls it "best-effort, and biased toward reporting a change", because "DbgEng has more ways
513 // to touch a target than a name list can enumerate". Keying on the token treats that list as
514 // complete. Any reported change therefore retires both, which costs a re-read and claims nothing
515 // the pane cannot support.
516 if (changed.length) forgetInventories(s, n)
517 const wrote = changed.length ? '; wrote ' + changed.join(', ') : ''
518 const after = obj(d.after)
519 if (!after) return wrote ? wrote.slice(2) : ''
520 const state = str(after.state)
521 const ip = norm(after.ip)
522 const why = str(after.why) ? ': ' + str(after.why) : ''
523 const by = str(after.by) ? ' by ' + str(after.by) : ''
524 if (state === 'stopped') {
525 // **The final position is not evidence that nothing ran**, and treating it as such was this
526 // branch's own bug. `SessionAfterInfo::Stopped` carries the instruction pointer and nothing
527 // else — there is no execution flag — and a step's `action` is rendered debugger *text*, which
528 // `.claude/rules/execution-waits.md` records as unfinishable to read for whether it reaches
529 // execution. A resume that loops back to the same breakpoint therefore stops exactly where it
530 // started, having changed every register and whatever the target wrote.
531 //
532 // An earlier round was right that marking every batch as movement makes an inspection look
533 // like execution, and that reading is kept — in the **note**, which is where a reading
534 // belongs. It cannot be kept in the staleness, because the two errors do not cost the same: a
535 // read drawn as current when the target has moved under it is the defect this whole pane is
536 // about, and a panel greyed after an inspection costs one re-read.
537 s.movedAt = n
538 if (!ip) return 'batch stopped, position not reported' + wrote
539 const same = ip === s.ip
540 s.ip = ip
541 s.ipAt = n
542 return 'batch stopped at ' + where(s, ip) + (same ? ', position unchanged' : '') + wrote
543 }
544 if (state === 'running') {
545 s.movedAt = n
546 return 'batch left the target running' + why + wrote
547 }
548 if (state === 'detached') {
549 s.movedAt = n
550 s.state = 'detached'
551 return 'batch detached the target' + by + wrote
552 }
553 if (state === 'ended') {
554 s.movedAt = n
555 s.gone = true
556 s.ip = null
557 return 'batch ended the target' + by + wrote
558 }
559 if (state === 'uncertain') {
560 // The server reports not knowing rather than guessing, so neither does the pane: what it
561 // cannot claim is that the reads taken before the batch still describe the target.
562 s.movedAt = n
563 return 'batch left the session uncertain' + why + wrote
564 }
565 return ''
566}
567
568/** A call answered: fold its typed result into the session it was routed to. */
569export function finish(v: View, n: number, tool: string, args: Record<string, unknown>, out: Outcome): View {
570 const d = out.data
571 const opened = OPEN.test(tool) && out.ok && d && typeof d.session_id === 'string' ? (d.session_id as string) : null
572 // An opener that fails *after* creating or claiming a target reports the only handle that
573 // reaches it in its structured error, and says in `target` whether something was left behind:
574 // `yes` (loaded, spawned or dialled), `pending` (the wait was abandoned, the open may still
575 // land) or `unknown` (controller ownership unresolved). Openers take no `session_id` argument,
576 // so without reading this the pane falls back to the previous current session — attributing the
577 // failed open to an unrelated target and hiding one that may need `session_status` or
578 // `end_session`. `no` is the clean case and is left alone.
579 const failure = !out.ok && d ? obj(d.error) : null
580 const left = !out.ok && d ? str(d.target) : ''
581 const stranded =
582 failure && typeof failure.session_id === 'string' && left !== '' && left !== 'no'
583 ? (failure.session_id as string)
584 : null
585 // `begin` already pinned which session this call was routed to, so use that rather than asking
586 // `v.current` again: calls overlap, and recomputing here misfiles a result whenever another call
587 // changed the current session while this one was outstanding — an implicit call that started on
588 // s1 folding into s2, which then draws s1's registers, stack and memory under the wrong target.
589 // The fallback is only for a call whose log entry has aged out of the ring.
590 const pinned = v.log.find(e => e.n === n)?.session
591 const sid =
592 opened ??
593 stranded ??
594 (pinned !== undefined ? pinned : typeof args.session_id === 'string' ? args.session_id : v.current)
595 let sessions = v.sessions
596 // Set only from `SessionInfo::current`, which is the server's own answer to which session an
597 // unaddressed call reaches. See the `current` computation at the end of this function.
598 let routed: string | null = null
599 let s: Session | null = null
600 if (sid) {
601 const found = sessions.find(x => x.id === sid)
602 // A session is otherwise invented only by a call that *succeeded*. A failure carrying an
603 // unknown, expired or mistyped `session_id` would mint one, label it open with no target, and
604 // — through `current` below — make the pane switch to it, hiding the real session at exactly
605 // the moment a stale-handle error needed reading. A failure against a session already known
606 // still updates its log, which is all a failure has to say.
607 if (found) {
608 s = { ...found }
609 sessions = sessions.map(x => (x.id === sid ? s! : x))
610 } else if (out.ok || sid === stranded) {
611 s = freshSession(v, sid)
612 sessions = [...sessions, s]
613 }
614 }
615 let note = ''
616 if (!out.ok) {
617 // The host's text is the ordinary source. A failure the *payload* alone reported — `status`
618 // with no `isError` beside it, which `answered` now catches — arrives with nothing in `text`,
619 // and its message is in the structured error.
620 note = out.error.split(/\r?\n/).find(l => l.trim()) ?? (failure ? str(failure.message, 'failed') : 'failed')
621 if (s && sid === stranded) {
622 // Not 'open': the open failed. What the pane has to carry is that a target exists and this
623 // is the handle for it, which is the difference between recovering and attaching twice.
624 //
625 // The three words are the three `TargetCreated` variants, said as that enum says them.
626 // `yes` is **not** an abandoned target: its doc is "the target exists … Do not open again;
627 // use the handle, or `end_session`", and it covers both a wait that failed and an open that
628 // worked whose follow-up report failed — the second being a session that is simply usable.
629 // Only the message prose tells those apart (`server.rs`, `report_only`), and prose is not
630 // something to branch on, so the pane says what is true of both rather than calling a
631 // working session stranded.
632 s.kind = str(d?.kind, tool)
633 s.state = left === 'pending' ? 'opening' : left === 'unknown' ? 'unresolved' : 'held'
634 s.target =
635 left === 'pending'
636 ? 'open may still land'
637 : left === 'unknown'
638 ? 'controller ownership unresolved'
639 : 'target exists; use this handle or end the session'
640 const what = left === 'pending' ? 'may still land' : left === 'unknown' ? 'ownership unresolved' : 'target held'
641 note = 'open failed, ' + what + ' as ' + s.label + ': ' + note
642 }
643 } else if (s && d) {
644 if (opened) {
645 s.kind = str(d.kind, tool)
646 s.target = str(d.target)
647 s.state = 'open'
648 s.gone = false
649 s.bugCheck = bugCheckOf(d)
650 note = 'session ' + s.label + ' opened' + (s.bugCheck ? ', bug check ' + s.bugCheck.code : '')
651 }
652 if (Array.isArray(d.registers)) {
653 s.prevRegs = s.regs
654 s.regs = (d.registers as unknown[]).flatMap(x => {
655 const o = obj(x)
656 return o && typeof o.name === 'string' ? [{ name: o.name, value: str(o.value) } as Reg] : []
657 })
658 s.regsAt = n
659 const ip = norm(d.instruction_pointer)
660 if (ip) { s.ip = ip; s.ipAt = n }
661 note = ip ? 'rip=' + regHex(ip) : ''
662 }
663 // `current_location` is the authoritative answer to "where is it now", and after a text-only
664 // seek it is the *only* one: `goto_position` reports no position at all, so a caller asking
665 // here is the pane's one way to learn it. Nothing but the register and run folds set the
666 // position, so a location query that succeeded left the previous instruction drawn and marked
667 // current. `location_state` is what says whether there is an address to read — a
668 // `context_unavailable` position has none, and carrying the state is better than reporting a
669 // query that answered as one that did nothing.
670 if (tool === 'current_location') {
671 const ip = norm(d.address)
672 if (ip) {
673 s.ip = ip
674 s.ipAt = n
675 const co = obj(d.coordinate)
676 note = 'at ' + (co && str(co.module) ? str(co.module) + '+' + str(co.rva) : where(s, ip))
677 } else {
678 note = 'no position: ' + str(d.location_state, 'unknown')
679 }
680 }
681 // A live Secure Kernel stop, answered by `sk_live_wait` and by `sk_live_registers` alike. None
682 // of the branches around this one see it: the registers are `registers.values[]` rather than an
683 // array of `{ name, value }`, and the position comes from the `rip` row rather than a field.
684 const skRegs = skRegisters(d)
685 if (skRegs) {
686 s.prevRegs = s.regs
687 s.regs = skRegs
688 s.regsAt = n
689 const ip = norm(skRegs.find(r => r.name === 'rip')?.value)
690 if (ip) { s.ip = ip; s.ipAt = n }
691 const stop = skStopOf(d, skRegs)
692 if (stop) {
693 s.sk = stop
694 s.skPhase = 'stopped'
695 note = 'VTL' + stop.vtl + ' CPL' + stop.cpl + ' ' + stop.reason +
696 (stop.slot !== null ? ' slot ' + stop.slot : '') + ' at ' + regHex(ip)
697 } else {
698 note = ip ? 'rip=' + regHex(ip) : ''
699 }
700 const failed = skRegs.filter(r => r.failed)
701 if (failed.length) note += ', ' + failed.length + ' register(s) unreadable'
702 }
703 const ins = obj(d.instruction)
704 if (ins && ins.address !== undefined) {
705 // Live control reports the bytes it guarded and no disassembly, so the row carries no text.
706 //
707 // **And the row is the instruction just *executed*, not the one at the stop.**
708 // `.claude/rules/secure-kernel-live.md` says so in as many words — "a stop's `instruction` is
709 // the one just stepped", and feeding it back to `sk_live_step` is refused with "the step
710 // instruction address … does not match the stopped RIP". Drawn as the current-stop listing it
711 // labelled the previous bytes as the stop and, since RIP is the *next* instruction, made the
712 // panel report the ip as elsewhere. `executed` is what the renderer reads to say what it is.
713 s.disasm = {
714 start: str(ins.address), bytesOnly: true, executed: true,
715 rows: [{ address: str(ins.address), bytes: insBytes(ins.bytes), module: '', rva: '', text: '' }],
716 }
717 }
718 if (SK_RESUME.test(tool) && typeof d.phase === 'string') {
719 s.skPhase = d.phase
720 s.movedAt = n
721 note = d.phase + (typeof d.epoch === 'string' ? ', epoch …' + d.epoch.slice(-4) : '')
722 }
723 if (Array.isArray(d.instructions)) {
724 const rows = (d.instructions as unknown[]).slice(0, DIS_KEEP).flatMap(x => {
725 const o = obj(x)
726 return o ? [{ address: str(o.address), bytes: str(o.bytes), module: str(o.module), rva: str(o.rva), text: str(o.text) } as Ins] : []
727 })
728 s.disasm = { start: str(d.start, rows[0]?.address ?? ''), rows }
729 note = rows.length + ' instructions'
730 }
731 if (Array.isArray(d.frames)) {
732 s.frames = (d.frames as unknown[]).flatMap(x => {
733 const o = obj(x)
734 return o ? [{
735 index: typeof o.index === 'number' ? o.index : 0, symbol: str(o.symbol), displacement: str(o.displacement),
736 module: str(o.module), rva: str(o.rva), address: str(o.address),
737 } as Frame] : []
738 })
739 s.framesTruncated = d.frames_truncated === true
740 s.framesAt = n
741 note = s.frames.length + ' frames'
742 }
743 // Three tools answer with bytes, and `sk_read_memory` was missing from the list — the one that
744 // matters most for the session it belongs to, because it is the *only* guest-memory read an
745 // `open_sk_capture` session has. Every capture read was therefore folded nowhere, leaving the
746 // Memory panel empty or still showing an older read with nothing saying so. Its
747 // `SecureKernelRead` carries the same `address`, `gpa`, `requested_size` and `data` as the live
748 // control's `LiveMemoryRead`, and a `symbol` beside them where the capture's own PDBs named the
749 // address.
750 if (/^(read_memory|sk_read_memory|sk_live_read_memory)$/.test(tool) && typeof d.data === 'string' && d.address !== undefined) {
751 const hex = d.data.replace(/[^0-9a-fA-F]/g, '')
752 s.mem = { address: str(d.address), hex: hex.slice(0, MEM_KEEP * 2), size: hex.length / 2 }
753 // A Secure Kernel read resolves through the guest's own page tables, so it can say which
754 // physical page answered — and a read shorter than the request is a fact the size would hide.
755 if (d.gpa !== undefined) s.mem.gpa = str(d.gpa)
756 const frame = readFrame(s.mem.hex)
757 if (frame) s.mem.frame = frame
758 s.memAt = n
759 note = hex.length / 2 + ' bytes' + (d.gpa !== undefined ? ' at gpa ' + regHex(d.gpa) : '')
760 if (typeof d.symbol === 'string' && d.symbol) note += ', ' + d.symbol
761 if (typeof d.requested_size === 'number' && d.requested_size !== hex.length / 2) {
762 // `read_size` cannot say *why* a read came up short — that is what `stopped` is for, and
763 // the two answers have different remedies: a deadline sends the caller back for the rest,
764 // an interrupt was their own doing. `WalkHalt::phrase` writes both in one place precisely
765 // so a wording invented per site cannot differ at one of them, so these are its words.
766 const halt = d.stopped === 'deadline' ? ', ran out of time' : d.stopped === 'interrupted' ? ', was interrupted' : ''
767 note += ', ' + d.requested_size + ' requested' + halt
768 }
769 }
770 if (Array.isArray(d.modules) && typeof d.loaded === 'number') {
771 // A fresh kernel attach holds only what it could read at connect time, so the jump a
772 // refresh produces is the fact worth showing (windbg-mcp#85).
773 const before = obj(d.refresh)?.before
774 note = d.loaded + ' loaded' + (typeof before === 'number' && before !== d.loaded ? ', refreshed from ' + before : '')
775 }
776 // `breakpoints` means two different things by tool, and the difference is in the field's own
777 // doc comment. On the `breakpoints` listing it *is* the answer, so empty means the session
778 // holds none. On a `set_breakpoint` it is an inspection taken after a mutation that has
779 // already happened, and best-effort: empty means it could not be read, and says nothing about
780 // the breakpoint this call made — that one is reported separately and authoritatively in
781 // `breakpoint`, which "there is no case in which it is unavailable". Replacing the inventory
782 // with the empty list hid both the breakpoint just armed and everything already known, drawing
783 // an unreadable session as a clean one.
784 if (Array.isArray(d.breakpoints) && (tool !== 'set_breakpoint' || d.breakpoints.length > 0)) {
785 s.bps = readBps(d.breakpoints)
786 s.bpsAt = n
787 if (tool === 'set_breakpoint') note = s.bps.length + ' breakpoint(s)'
788 } else if (tool === 'set_breakpoint') {
789 const set = readBps([d.breakpoint])
790 // `replaced` names the breakpoints the engine removed to make room at the same resolved
791 // address, so they go rather than staying on screen beside their replacement.
792 const replaced = Array.isArray(d.replaced) ? d.replaced : []
793 const kept = (s.bps ?? []).filter(b => !replaced.includes(b.id) && !set.some(x => x.id === b.id))
794 s.bps = [...kept, ...set]
795 s.bpsAt = n
796 note = (set.length ? 'breakpoint ' + set[0]!.id + ' set' : 'set') + ', inventory could not be read'
797 }
798 // A clear answers with `remaining` and carries no `breakpoints` at all, so the branch above
799 // never fired and the pane went on drawing the cleared entries as armed. `remaining` is
800 // nullable where every other listing is not, deliberately: an empty array is the success state
801 // after clearing everything, and `null` means the inventory could not be read. The pane's own
802 // `bps: Bp[] | null` says the same thing — null hides the panel rather than asserting a count —
803 // so the two meanings are kept apart instead of collapsing an unreadable session into a clean
804 // one.
805 if (tool === 'clear_breakpoints' && 'remaining' in d) {
806 const remaining = d.remaining
807 if (Array.isArray(remaining)) {
808 s.bps = readBps(remaining)
809 s.bpsAt = n
810 note = s.bps.length === 0 ? 'all breakpoints cleared' : s.bps.length + ' breakpoint(s) remaining'
811 } else {
812 s.bps = null
813 s.bpsAt = n
814 note = 'cleared, inventory could not be read'
815 }
816 }
817 // Three different shapes report a run, and reading only the synchronous one left an async run
818 // invisible: the pane kept prior registers, stack and memory marked current while the target
819 // was moving, then never picked up the stop. `go`/`step_*` answer with `stopped_at` at the top
820 // level; `continue_async` answers with `moved`/`running` and no stop at all, because the point
821 // of it is that the caller left; `wait_for_stop` nests the stop under `stop`; and
822 // `run_to_address` reports an ending through `verdict` rather than the boolean.
823 const stop = obj(d.stop) ?? d
824 const gone = stop.target_gone === true || d.verdict === 'target_gone'
825 // `run_to_address` reports a timeout as a verdict and deliberately omits `stopped_at`, since
826 // echoing the address asked for would say execution got there. The target still ran for the
827 // whole wait, so this is movement with no position — the one combination that reads as "no
828 // movement" if only the position is looked for.
829 const ranOn = d.verdict === 'timeout' || (d.running === true && !('stopped_at' in stop))
830 // **A stop that cannot say where is still a stop**, and looking for the position alone read it
831 // as no movement at all. `StopReport::stopped_at` is absent "for a target that is running,
832 // gone, or has **no thread context**" — a module-load break, a bare `goto_position` to the
833 // start of a trace — and `target_gone`'s own doc says it is a separate field precisely because
834 // "a target with no thread context also produces" an absent position. So a successful `go`,
835 // step, reverse or nested `wait_for_stop` can carry none of the four conditions above while
836 // having moved the target, and the pane went on marking the pre-run registers, stack and memory
837 // current. The tell is the report itself: `command` and `output` are plain `String`s that every
838 // `StopReport` carries, where `ExecutionStarted` has the first and not the second and
839 // `RunToReport` the second and not the first — so this recognises a stop without claiming one
840 // from either of those.
841 const stopped = typeof stop.command === 'string' && typeof stop.output === 'string'
842 if (RUN.test(tool) && ('stopped_at' in stop || stopped || gone || ranOn || d.moved === true)) {
843 s.movedAt = n
844 // The run this call is about, so a later `session_status` can tell it from one the pane
845 // never saw. `continue_async` and `wait_for_stop` both name it; the synchronous tools do not
846 // and leave it alone.
847 if (typeof d.execution === 'string' && d.execution) s.execSeen = d.execution
848 if (gone) {
849 s.gone = true
850 s.ip = null
851 note = 'target ended'
852 } else if (ranOn) {
853 // Deliberately no position: the target moved, so every earlier read is now stale and
854 // saying where it *was* would be the misreading this exists to prevent.
855 note =
856 d.verdict === 'timeout'
857 ? 'ran on, no stop within the wait'
858 : 'running' + (typeof d.execution === 'string' ? ', execution ' + d.execution : '')
859 } else {
860 const ip = norm(stop.stopped_at)
861 if (ip) { s.ip = ip; s.ipAt = n }
862 const how = stop.interrupted === true ? 'broken in' : stop.timed_out === true ? 'wait ran out' : 'stopped'
863 // No position is not no movement, and `where` answers the empty string for one — so the
864 // note said "stopped at " and the panels would have kept the position the target left.
865 note = ip ? how + ' at ' + where(s, ip) : how + ', no position reported'
866 }
867 }
868 // Last, deliberately: `crash_triage` also carries `frames`, and the bug check is the headline
869 // rather than how many frames came with it.
870 if (tool === 'crash_triage') {
871 const bc = bugCheckOf(d)
872 if (bc) {
873 s.bugCheck = bc
874 note = bc.code + ' ' + bc.name + (bc.blamed ? ', blaming ' + bc.blamed : '')
875 }
876 }
877 // `session_status` is the only tool that reports what the *server* holds, and it is the answer
878 // to a session a failed open left `opening`, `stranded` or `unresolved`: the comment at the
879 // top of this function sends a caller here, and the pane read nothing back — so a provisional
880 // state stayed on screen after the worker had resolved it one way or the other, which is the
881 // recovery flow those sessions are kept for. Rows fold into the sessions the pane already
882 // knows; a listing does not mint one, because the pane follows calls and a session it has seen
883 // no call for has no reads to draw under it.
884 if (tool === 'session_status' && Array.isArray(d.sessions)) {
885 const said: string[] = []
886 for (const row of d.sessions as unknown[]) {
887 const o = obj(row)
888 const id = o ? str(o.session_id) : ''
889 const known = id ? sessions.find(x => x.id === id) : undefined
890 if (!o || !known) continue
891 const t = known === s ? s : { ...known }
892 if (t !== known) sessions = sessions.map(x => (x === known ? t : x))
893 const state = sessionState(obj(o.state))
894 if (state) t.state = state
895 t.kind = str(o.kind, t.kind)
896 if (str(o.target)) t.target = str(o.target)
897 // `execution.stopped` is the one field here that is not about the session's lifetime: it
898 // says whether the run this session has outstanding has ended, and while it is false "the
899 // target is moving and every tool that reads it is refused". So this is also the recovery
900 // answer for a `continue_async` whose own call timed out — that failure advances nothing,
901 // and until this is read the pane goes on drawing the pre-run reads as current.
902 // **Both answers mean the target ran**, and reading only `stopped: false` caught one of
903 // them. The case this is the recovery for is a `continue_async` whose own call timed out:
904 // the pane saw a failure, so nothing recorded movement — and if the run then finished
905 // before the first poll, `stopped` is already `true` and the old condition folded nothing,
906 // leaving the pre-run registers, stack, memory and position marked current.
907 //
908 // What separates "a run the pane has accounted for" from one it has not is the **handle**,
909 // not the flag: `ExecutionInfo` keeps a finished run's stop "until another run replaces
910 // it", so the block is still there long after the target stopped, and advancing on its
911 // mere presence would re-stale reads taken *after* that stop.
912 const ex = obj(o.execution)
913 const moving = ex?.stopped === false
914 const handle = ex ? str(ex.execution) : ''
915 const unreported = !!ex && handle !== '' && handle !== t.execSeen
916 if (moving || unreported) t.movedAt = n
917 if (handle) t.execSeen = handle
918 // `SessionInfo::current` — "whether a call that names no session is routed here" — is the
919 // authority, and declining to read it last round was wrong. The objection was that it
920 // answers about one instant and would need somewhere to live; `current` on the view is
921 // that somewhere, now that it is a belief rather than a per-call derivation. It is the
922 // only way the pane learns that the server has moved off a session whose ineligible state
923 // the pane cannot name — a KD phase, say.
924 if (o.current === true) routed = t.id
925 said.push(t.label + ' ' + t.state +
926 (moving ? ', target moving' : unreported ? ', a run this session had not reported' : ''))
927 }
928 // An `unknown_handle` is the complement in the same payload, and reading `sessions` alone
929 // conflates them: the list is empty either way, so a handle the server has never heard of
930 // would read exactly like one that closed. Only the session actually asked about is touched.
931 if (d.unknown_handle === true && s && str(d.asked) === s.id) {
932 s.state = 'unknown to the server'
933 said.push(s.label + ' unknown')
934 }
935 note = said.length ? said.join(', ') : (d.sessions as unknown[]).length + ' session(s)'
936 }
937 // `released` says whether the **worker** let go of the target itself, not whether the session
938 // is still open: one that had to be killed holding its target answers `false` while the
939 // supervisor has closed the session anyway, and reading it as the state left such a session
940 // marked `unresolved` for ever. `recovery_required` is the field that says a teardown is
941 // unconfirmed and wants out-of-band work — "this response is not a confirmed detach" — and
942 // only the preserved live-control case sets it. So it decides the state, and the other two
943 // facts go where facts go.
944 if (tool === 'end_session') {
945 s.state = d.recovery_required === true ? 'unresolved' : 'closed'
946 // `target_left_running` is absent for a dump or a trace, which have nothing that outlives
947 // the session. For a live kernel `false` is the one that matters: it was *not* resumed, so
948 // it is halted at a break with one processor stopped and the rest spinning.
949 const left = d.target_left_running === true
950 ? ', target left running'
951 : d.target_left_running === false ? ', target did not outlive the session' : ''
952 note = (d.released === false ? 'the worker did not release the target' : 'released') + left +
953 (d.recovery_required === true ? ', recovery required' : '')
954 }
955 } else if (s && RUN.test(tool)) {
956 // A successful run with no typed payload still moved the target. `goto_position` seeks a TTD
957 // trace by running `!tt`, which answers with debugger text, so there is nothing to fold and
958 // every branch above is skipped — leaving the pane showing the position before the seek and
959 // calling the registers, stack and memory read at it current. The movement is known from the
960 // call succeeding; the new position is not, so it is marked moved and nothing is invented.
961 s.movedAt = n
962 note = 'moved, position not reported'
963 }
964 // The raw `execute` hatch can move the target and cannot say so: it declares no output schema,
965 // so the whole answer is debugger text and there is no field to read. What the pane must not do
966 // is go on claiming the earlier reads are current — a `g` through this hatch leaves registers,
967 // stack and memory describing a target that is somewhere else now.
968 //
969 // There is deliberately **no list of command names**, which is what review asked for.
970 // `.claude/rules/execution-waits.md` records why one cannot be finished: `bp X; g`, an alias,
971 // `.if (1) { g }` and `dx …ExecuteCommand("g")` all reach execution without saying so, and the
972 // server's own fix was to ask the engine rather than the text. The pane cannot ask the engine, so
973 // the honest reading is that a raw command *may* have moved the target — `movedAt` says only
974 // that the reads before it are no longer known to be current, which is exactly the claim left.
975 // Reading the text instead would be inventing a position out of a rendering.
976 if (s && out.ok && tool === 'execute') {
977 s.movedAt = n
978 // The two inventories movement does not cover go with it. `bc *` retires every breakpoint and
979 // `eb` rewrites the code, and neither is movement — so staling only the position and the three
980 // timestamped reads left breakpoints drawn as armed and a listing drawn as the instructions at
981 // those addresses. This is the same weaker claim the hatch already makes rather than a reading
982 // of the command text: recognising the forms is what `.claude/rules/execution-waits.md` records
983 // as unfinishable, so the pane retires what it can no longer vouch for and says so.
984 forgetInventories(s, n)
985 if (!note) note = 'raw command, target state not reported'
986 }
987 // Outside that chain on purpose — see `foldBatch`. A failed batch keeps the failure in front of
988 // what the report says the session is left holding, since both are facts the reader wants.
989 if (s && d && tool === 'debug_batch') {
990 const said = foldBatch(s, d, n)
991 if (said) note = note ? note + '; ' + said : said
992 }
993 // **A belief updated by the events that change it, never a value derived per call.** Two rounds
994 // landed here. First the pane followed whichever session a call *named*, which the server's
995 // default ignores — `Sessions::current` is
996 // `self.all.iter().rev().find(|s| s.state().accepts_default())` and no addressed call moves it,
997 // so an explicit call to the older of two sessions pointed the pane there while implicit calls
998 // kept going to the newer, and one target's reads were drawn under another. Deriving it per call
999 // was the fix and was wrong the same way one layer down: it put `accepts_default` inside the
1000 // pane, whose `state` is a display string, so a newest session gone `failed` or into a
1001 // non-routable KD phase was still chosen. So it moves on three things and nothing else: an
1002 // opener (the new session is the newest and accepts a default call), a `session_status` row
1003 // carrying `current` (the server's own answer, and the authority), and the believed session
1004 // ceasing to be eligible — where the fallback is a guess the next status call corrects.
1005 const believed = v.current && sessions.some(x => x.id === v.current && !NOT_ROUTABLE.has(x.state))
1006 const current = opened ?? routed ?? (believed ? v.current : newestOpen(sessions) ?? v.current)
1007 const log = v.log.map(e =>
1008 e.n === n ? { ...e, session: sid, status: out.ok ? ('ok' as const) : ('error' as const), note, ms: out.ms } : e,
1009 )
1010 return { ...v, sessions, current, log }
1011}
1012types/index.d.ts 130 lines1/**
2 * One register. A live Secure Kernel stop reports a `high` half and a read `status` beside the
3 * value and keeps both deliberately — `cs` carries its descriptor in the high half, and a
4 * non-zero status is a register that could not be read rather than one that is zero — so both
5 * are carried here rather than flattened away.
6 */
7export type Reg = { name: string; value: string; high?: string; failed?: boolean }
8
9export type Ins = { address: string; bytes: string; module: string; rva: string; text: string }
10
11/**
12 * A live Secure Kernel VTL1 stop. `cr3` is the page-table root in force at the stop and
13 * `expectedCr3` the session's provider-hello baseline; the two differing is the transition case
14 * `allow_transition_cr3` admits. Which address space either root *is* does not follow from the
15 * reading — one live sample of a VP's CR3 establishes no owner — so neither is labelled with one.
16 */
17export type SkStop = {
18 vtl: number
19 cpl: number
20 vp: number
21 vector: number
22 reason: string
23 slot: number | null
24 partition: string
25 expectedCr3: string
26 cr3: string
27 epoch: string
28}
29
30export type Frame = {
31 index: number
32 symbol: string
33 displacement: string
34 module: string
35 rva: string
36 address: string
37}
38
39export type Bp = { id: number; address: string; expression: string; enabled: boolean; oneShot: boolean }
40
41/** What the pane knows about one windbg-mcp session, each read stamped with the call that made it. */
42export type Session = {
43 id: string
44 label: string
45 kind: string
46 target: string
47 state: string
48 regs: Reg[] | null
49 prevRegs: Reg[] | null
50 regsAt: number
51 ip: string | null
52 ipAt: number
53 movedAt: number
54 /**
55 * `bytesOnly` where the source reported instruction bytes and no disassembly, as SK live does.
56 *
57 * `executed` where the row is the instruction the target **just ran** rather than a listing at
58 * the current position — a live Secure Kernel stop reports the instruction it stepped, so RIP is
59 * the one after it. Without the distinction the panel named the previous bytes as the stop and
60 * reported the ip as elsewhere.
61 */
62 disasm: { start: string; rows: Ins[]; bytesOnly?: boolean; executed?: boolean } | null
63 frames: Frame[] | null
64 framesTruncated: boolean
65 framesAt: number
66 /**
67 * `gpa` is the guest physical address a live VTL1 page-table walk resolved the read to, and
68 * `frame` is an x64 machine frame recognised in the bytes — `rip, cs, rflags, rsp, ss` — offered
69 * only where the shape fits, since most reads are not frames.
70 */
71 mem: {
72 address: string
73 hex: string
74 size: number
75 gpa?: string
76 frame?: { rip: string; cs: string; rflags: string; rsp: string; ss: string; ring: number }
77 } | null
78 memAt: number
79 bps: Bp[] | null
80 /**
81 * The call the inventory above was read on, so a one-shot breakpoint consumed by a later run is
82 * not drawn as still armed. The other four reads are stamped for staleness against `movedAt`;
83 * this one is not stale on movement in general — an ordinary breakpoint survives a `go` — only
84 * where the inventory holds a `oneShot`, which DbgEng removes when it is hit.
85 */
86 bpsAt: number
87 /**
88 * The `continue_async` handle of the last run the pane has accounted for, or `''`.
89 *
90 * The identity of a run rather than a flag about one, because `session_status` keeps a finished
91 * run's block until another replaces it — so "is there an execution block" cannot distinguish a
92 * run the pane folded from one it never saw, which is what a `continue_async` whose own call
93 * timed out leaves behind.
94 */
95 execSeen: string
96 gone: boolean
97 /** Set on a target that has bug checked, from the opener's summary or from `crash_triage`. */
98 bugCheck: { code: string; name: string; blamed: string } | null
99 /** The last live Secure Kernel stop, where this is one of those sessions. */
100 sk: SkStop | null
101 /** `running` once an SK arm/step/continue was accepted, until the next stop is collected. */
102 skPhase: string
103}
104
105export type LogEntry = {
106 n: number
107 tool: string
108 command: string
109 isWinDbg: boolean
110 session: string | null
111 status: 'running' | 'ok' | 'error'
112 note: string
113 ms: number
114}
115
116export type View = {
117 n: number
118 current: string | null
119 sessions: Session[]
120 log: LogEntry[]
121 autoOpened: boolean
122 lastShape: string
123}
124
125declare module 'claude-code' {
126 interface PluginState {
127 'windbg-pane': { view: View }
128 }
129}
130