- Native step/cont moved to __step_native/__cont_native; public step()/cont()
defined in prelude.js wrap them and dispatch JS listeners on each stop.
- addEventListener/removeEventListener/clearListeners/listeners() registry on
globalThis, persistent for the daemon lifetime (independent of ctx.reset()).
- Event object carries {state, prev, stop_reason} plus breakpoint_id/signal/
exit_code as appropriate. Implements the SPEC 'Event Listeners' + 'Core Loop'.
- No-listener fast path keeps existing scripts zero-cost.
- examples/smarm_ctx_switch.js: worked session inspecting smarm's naked-asm
context switch; README updated (status table, listener API, deferred list).
llmdbg
A scriptable, stateful debugger REPL with JavaScript as the interface language.
Built from the spec in SPEC.md. This repo holds the v0.1–v0.6
implementation: enough to actually drive a debugged process from an agent
harness with breakpoints, disassembly, and symbol-aware addresses.
The premise: existing debuggers optimise for humans at a terminal. An LLM
agent is a different user — it wants terse output it can parse, lazy state
access, and a scripting layer rich enough that "describe what you want to see
each step" is the natural interface. The full motivation is in SPEC.md.
Status
| Version | What landed |
|---|---|
| v0.1 | Spawn, step, continue, basic state (pc/sp/regs/memory), persistent ctx, print(), JS prelude loaded via include_str! |
| v0.2 | prev register snapshot before each resume; refactored register accessors |
| v0.3 | ELF symbol parsing of main exe; sym(addr) / addr(name) / symbols(); addrstr / here prelude helpers |
| v0.4 | INT3 software breakpoints with by-name/by-id ops; auto disarm-step-rearm; RIP rewind on hit |
| v0.5 | x86-64 disassembly via iced-x86; read_mem_pristine() overlays original bytes through active INT3s; branch targets get symbolised |
| v0.6 | state.stack; until(loc); finish(); show.{disasm,bp,regs} prelude helpers |
| v0.7 | Event-listener system: addEventListener('step'|'breakpoint'|'signal'|'exit', ...), removeEventListener, clearListeners, listeners(); step/cont fire listeners per stop with {state, prev, stop_reason, ...} events; native steppers moved to __step_native/__cont_native |
Each vN corresponds to one git commit. See git log --oneline.
Not yet implemented
The spec calls for these; they're deliberately deferred:
attach(pid)— onlyspawnworks today.- Hardware watchpoints. (The
watchpointevent type exists in the listener registry but never fires until hardware watchpoints land.) - Source-level debugging via DWARF (
gimli). - Multi-threaded targets; shared-library symbol resolution.
- Browser visualisation / DOM API.
- Conditional breakpoints. (Now trivial to express as a guard inside a
breakpointlistener, but native--conditionsupport is still deferred.) - The
--summarymode for batch stepping. - Reverse execution.
The event-listener system (addEventListener('step', ...) etc.) landed in
v0.7 — see the status table.
The complete "what not yet" list is in SPEC.md.
Build
Requires a Linux x86-64 host with ptrace permitted (no yama ptrace_scope
restriction in the way) and a recent stable Rust toolchain (1.87+).
cargo build # debug
cargo build --release # ~3-5x faster
QuickJS builds via rquickjs-sys's pre-generated bindings; no libclang or
bindgen needed on the supported platforms.
Run
The binary is a daemon. It reads JS scripts from stdin and writes results to stdout, with sentinels separating turns:
$ ./target/debug/llmdbg <<'EOF'
spawn("/tmp/testprog")
bp.set("main")
cont()
show.disasm(5)
---END---
EOF
Protocol:
- Read lines until a line containing exactly
---END---. - Evaluate the accumulated buffer as one JS script in the persistent context.
- Print: any
print()output, then the final expression's value (when notundefined), orerror: ...if the eval threw. - Print
---DONE---and flush. - Repeat.
- EOF on stdin exits cleanly. If a target is still alive, it gets SIGKILL'd.
Quick tour
A representative session against a small C program:
spawn("/tmp/testprog")
bp.set("main")
bp.set("add", "add_bp")
show.bp()
// #1 0x55ca2862c1e8 (main) hits=0 enabled
// #2 "add_bp" 0x55ca2862c149 (add) hits=0 enabled
---END---
cont() // -> "breakpoint:1"
here() // -> 0x55ca2862c1e8 (main)
show.disasm(5)
// 0x55ca2862c1e8 f30f1efa endbr64
// 0x55ca2862c1ec 55 push rbp
// ...
---END---
cont() // -> "breakpoint:2"
here() // -> 0x55ca2862c149 (add)
show.regs(["rip","rdi","rsi"]) // (rdi, rsi are the first two args)
// rip=0x55... rdi=0x5 rsi=0x1
---END---
step(5)
finish() // -> "breakpoint:N", back at caller
state.registers.rax & 0xffffffffn // -> 0x6 (the eax return value)
---END---
cont() // -> "exited:0"
---END---
The native surface
Everything below is installed in the global scope by the Rust side. The JS
prelude (src/prelude.js) adds the helpers listed at the bottom.
// Process control
spawn(path, ...args) // -> { pid, status: "stopped_at_entry" }
step(n = 1) // -> stop-reason string
cont() // -> stop-reason string (NOT `continue` — JS keyword)
// Stop reasons (returned as strings):
// "step", "breakpoint:N", "signal:SIGSEGV", "exited:0", "killed:SIGKILL"
// Live state (each property re-reads via ptrace on access)
state.pc // -> BigInt
state.sp // -> BigInt
state.registers // -> { rax, rbx, ..., r15, eflags } all BigInt
state.memory(addr) // -> { u8(), u32(), u64(), i64(), bytes(n) }
state.disasm(n = 8) // -> [{addr, bytes, text, target?, target_sym?}]
state.stack // -> { top, usable_base, guard_size }
// Snapshot before the most recent step/cont — same shape as state for regs.
// Throws if no step has happened yet.
prev.pc / prev.sp / prev.registers
// Symbols (main exe only; shared libs deferred)
sym(addr) // -> "name+0xN" | "name" | null
addr(name) // -> BigInt | null
symbols() // -> { count, load_base, path }
disasm(addr, n = 8) // -> same shape as state.disasm
// Breakpoints (`breakpoint` and `bp` are the same object)
bp.set(addr_or_symbol, name?) // -> id
bp.remove(id_or_name)
bp.list() // -> [{id, addr, name, hits, enabled}]
// User scratchpad (persistent across all evals)
ctx.something = whatever
Prelude (src/prelude.js)
Pure JS, loaded last so it can reference the natives. Edit it freely; it gets
re-embedded into the binary on every cargo build via include_str!.
hex(v) // -> "0x..." for Number or BigInt
unhex(s) // -> BigInt (handles "0x", case, sign)
dump(label, v) // -> print one terse "label\tvalue" line
repr(v) // -> string form; hex for numerics, JSON for objects
addrstr(v) // -> "0xN (sym+0xoff)" when sym is known, else "0xN"
here(label?) // -> print addrstr(state.pc), optionally labelled
until(loc) // one-shot bp at addr-or-symbol, cont, cleanup
finish() // until([rbp+8]) — assumes frame pointer
show.disasm(n=8, [at])
show.bp()
show.regs([keys])
// Event listeners (v0.7). Handlers fire on each stop during step()/cont().
addEventListener(type, handler) // type: 'step'|'breakpoint'|'signal'|'exit'
removeEventListener(type, handler)
clearListeners([type]) // drop one type, or all if omitted
listeners() // -> { step: n, breakpoint: n, ... } counts
Event listeners
The event object passed to each handler:
e.state // live state (re-reads via ptrace), same as the `state` global
e.prev // pre-resume snapshot, or null on the very first event
e.stop_reason // "step" | "breakpoint:N" | "signal:SIGSEGV" | "exited:0" | ...
e.breakpoint_id // number — only on 'breakpoint' events
e.signal // string — only on 'signal' events
e.exit_code // number — only on 'exit' events
step(n) single-steps one instruction at a time, firing listeners after each
stop; cont() fires after the stop it lands on. When no listeners are
registered, both take the native fast path and pay nothing. Handler exceptions
are caught and printed, so one bad listener won't abort a long step loop.
A worked example against smarm's context switch is in
examples/smarm_ctx_switch.js.
Architecture
src/
├── main.rs ─ stdin read loop, ---END--- framing, SIGKILL on exit
├── target.rs ─ ptrace child management: spawn, step, cont, regs,
│ process_vm_readv, breakpoint patching (read-byte+poke-byte
│ via PEEKDATA/POKEDATA word RMW)
├── symbols.rs ─ ELF parsing via `object`; PIE load-base from /proc/maps;
│ StackRegion from /proc/maps
├── breakpoint.rs ─ BreakpointSet storage (by id, by addr, by name)
├── disasm.rs ─ thin wrapper around iced-x86 FastFormatter; emits records
│ with branch_target for caller symbolisation
├── js.rs ─ Runtime + Context + Rc<RefCell<Option<Target>>>;
│ installs all native globals, then evals prelude.js
└── prelude.js ─ embedded at compile time
A few decisions worth knowing
- BigInt for u64. A JS Number above 2⁵³ loses precision, which is exactly
the range we live in.
state.pc,addr(...), anything 64-bit goes in and out as BigInt.state.memory(addr).u8()and.u32()stay as Number. - Lazy state. Every access to
state.pcdoes a freshPTRACE_GETREGS; everystate.memory(...)does a freshprocess_vm_readv. A script that touches three fields makes three syscalls, not 20. Worth it for scripts; the cache would just be a footgun once you have multi-threaded targets. continue()doesn't exist.continueis a reserved keyword in JS, so the natural name is a parse error. The spec'scontinue()iscont()here.- Single QuickJS context for the daemon's lifetime.
ctx(the JS scratchpad), registered listeners (when they land), and any user-defined helpers all persist. Only resets on daemon restart. - Read-modify-write breakpoint patching.
ptrace::writewrites a full word. Weptrace::readfirst, modify byte 0, write the word back. The other seven bytes stay unchanged because we just read them. read_mem_pristine. Used by the disassembler so it sees the original byte, not 0xCC, at active breakpoints. Without this, adisasmover a bp'd function showsint3; ...at the first byte.- PIE load base. Computed as
mapping_base - mapping_file_offsetof the first mapping of the executable in/proc/<pid>/maps. Earlier versions used the first executable mapping and dropped the file offset, which is wrong on toolchains that split read-only header pages.
Caveats
- Linux x86-64 only. No multi-arch, no other OSes.
- Single-threaded debuggees. A pthread'd binary will behave oddly the moment a non-main thread does anything.
- Symbols only cover the main executable. Shared-library functions (e.g.
printf,malloc) resolve tonulluntil ld.so awareness lands. finish()assumes a frame pointer (-fno-omit-frame-pointer, which is the default on most distros). It will throw ifrbp == 0.- The sandbox guard-page detection in
state.stack.guard_sizereturns 0 on Linux configurations where the kernel handles guard implicitly without an anonymous---pmapping below the stack. That's most modern setups. - No
attach(pid)yet — only spawn.
License
Personal experiment, no license declared. Treat as all-rights-reserved by the author until otherwise noted.