SLOPSHOPPER

windbg-pane

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

newpaneguardcommand
★ 16v0.1.0MITupdated 2026-10-15glslang/windbg-mcp/mods/windbg-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · windbg-pane
│ ┃ WinDbg ✕ › fix the failing auth test and add an audit log call │ ┃ WinDbg │ ┃ Waiting for a windbg-mcp call. Open a target ⏺ Read(src/auth.ts) │ ┃ (launch, open_dump, attach_kernel, ...) and ⎿ Read 6 lines │ ┃ this pane follows it. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /windbg-pane │ ⎿ windbg-pane: WinDbg pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · WinDbg
WinDbg Waiting for a windbg-mcp call. Open a target (launch, open_dump, attach_kernel, ...) and this pane follows it.
README

windbg-pane

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.

What it is for

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.

The thing to know before editing it

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:

  • a kernel target reports 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;
  • a live Secure Kernel stop reports its registers as 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.

Source 3 files
hooks/register.tsx 516 lines
1import { 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}
516
hooks/model.ts 1012 lines
1// 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}
1012
types/index.d.ts 130 lines
1/**
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