Node.js Inspect Debugger
Overview
When
console.log
isn't enough, drive Node's built-in V8 inspector programmatically from the terminal. You get real breakpoints, step in/over/out, call-stack walking, local/closure scope dumps, and arbitrary expression evaluation in the paused frame.
Two tools, pick one:
node inspect
— built-in, zero install, CLI REPL. Best for quick poking.
ndb
/ CDP via chrome-remote-interface
— scriptable from Node/Python; best when you want to automate many breakpoints, collect state across runs, or debug non-interactively from an agent loop.
Prefer node inspect
first. It's always available and the REPL is fast.
When to Use
- A Node test fails and you need to see intermediate state
- ui-tui crashes or behaves wrong and you want to inspect React/Ink state pre-render
- tui_gateway child processes (
_SlashWorker
, PTY bridge workers) misbehave
- You need to inspect a value in a closure that
console.log
can't reach without patching
- Perf: attach to a running process to capture a CPU profile or heap snapshot
Don't use for: things
console.log
solves in under a minute. Breakpoint-driven debugging is heavier; use it when the payoff is real.
Quick Reference: node inspect
REPL
Launch paused on first line:
node inspect path/to/script.js
# or with tsx
node --inspect-brk $(which tsx) path/to/script.ts
The
debug>
prompt accepts:
| Command | Action |
|---|
c or cont
| continue |
n or next
| step over |
s or step
| step into |
o or out
| step out |
pause
| pause running code |
sb('file.js', 42)
| set breakpoint at file.js line 42 |
sb(42)
| set breakpoint at line 42 of current file |
sb('functionName')
| break when function is called |
cb('file.js', 42)
| clear breakpoint |
breakpoints
| list all breakpoints |
bt
| backtrace (call stack) |
list(5)
| show 5 lines of source around current position |
watch('expr')
| evaluate expr on every pause |
watchers
| show watched expressions |
repl
| drop into REPL in current scope (Ctrl+C to exit REPL) |
exec expr
| evaluate expression once |
restart
| restart script |
kill
| kill the script |
.exit
| quit debugger |
In the repl
sub-mode: type any JS expression, including access to locals/closure variables.
Ctrl+C
exits back to
debug>
.
Attaching to a Running Process
When the process is already running (e.g. a long-lived dev server or the TUI gateway):
# 1. Send SIGUSR1 to enable the inspector on an existing process
kill -SIGUSR1 <pid>
# Node prints: Debugger listening on ws://127.0.0.1:9229/<uuid>
# 2. Attach the debugger CLI
node inspect -p <pid>
# or by URL
node inspect ws://127.0.0.1:9229/<uuid>
To start a process with the inspector from the beginning:
node --inspect script.js # listen on 127.0.0.1:9229, keep running
node --inspect-brk script.js # listen AND pause on first line
node --inspect=0.0.0.0:9230 script.js # custom host:port
For TypeScript via tsx:
node --inspect-brk --import tsx script.ts
# or older tsx
node --inspect-brk -r tsx/cjs script.ts
Programmatic CDP (scripting from terminal)
When you want to automate — set many breakpoints, capture scope state, script a repro — use
chrome-remote-interface
:
npm i -g chrome-remote-interface # or project-local
# Start your target:
node --inspect-brk=9229 target.js &
Driver script (save as
/tmp/cdp-debug.js
):
const CDP = require('chrome-remote-interface');
(async () => {
const client = await CDP({ port: 9229 });
const { Debugger, Runtime } = client;
Debugger.paused(async ({ callFrames, reason }) => {
const top = callFrames[0];
console.log(`PAUSED: ${reason} @ ${top.url}:${top.location.lineNumber + 1}`);
// Walk scopes for locals
for (const scope of top.scopeChain) {
if (scope.type === 'local' || scope.type === 'closure') {
const { result } = await Runtime.getProperties({
objectId: scope.object.objectId,
ownProperties: true,
});
for (const p of result) {
console.log(` ${scope.type}.${p.name} =`, p.value?.value ?? p.value?.description);
}
}
}
// Evaluate an expression in the paused frame
const { result } = await Debugger.evaluateOnCallFrame({
callFrameId: top.callFrameId,
expression: 'typeof state !== "undefined" ? JSON.stringify(state) : "n/a"',
});
console.log('state =', result.value ?? result.description);
await Debugger.resume();
});
await Runtime.enable();
await Debugger.enable();
// Set a breakpoint by URL regex + line
await Debugger.setBreakpointByUrl({
urlRegex: '.*app\\.tsx$',
lineNumber: 119, // 0-indexed
columnNumber: 0,
});
await Runtime.runIfWaitingForDebugger();
})();
Run it:
node /tmp/cdp-debug.js
Hermes-specific note:
chrome-remote-interface
is NOT in
ui-tui/package.json
. Install it to a throwaway location if you don't want to dirty the project:
mkdir -p /tmp/cdp-tools && cd /tmp/cdp-tools && npm i chrome-remote-interface
NODE_PATH=/tmp/cdp-tools/node_modules node /tmp/cdp-debug.js
Debugging Hermes ui-tui
The TUI is built Ink + tsx. Two common scenarios:
Debugging a single Ink component under dev
ui-tui/package.json
has
npm run dev
(tsx --watch). Add
--inspect-brk
by running tsx directly:
cd /home/bb/hermes-agent/ui-tui
npm run build # produce dist/ once so transpile isn't needed on first load
node --inspect-brk dist/entry.js
# In another terminal:
node inspect -p <node pid>
Then inside
debug>
:
sb('dist/app.js', 220) # or wherever the suspect render is
cont
When it pauses,
repl
→ inspect
props
, state refs,
useInput
handler values, etc.
Debugging a running hermes --tui
The TUI spawns Node from the Python CLI. Easiest path:
# 1. Launch TUI
hermes --tui &
TUI_PID=$(pgrep -f 'ui-tui/dist/entry' | head -1)
# 2. Enable inspector on that Node PID
kill -SIGUSR1 "$TUI_PID"
# 3. Find the WS URL
curl -s http://127.0.0.1:9229/json/list | jq -r '.[0].webSocketDebuggerUrl'
# 4. Attach
node inspect ws://127.0.0.1:9229/<uuid>
Interacting with the TUI (typing in its window) continues to advance execution; your debugger can pause it on a breakpoint at any
sb(...)
.
Debugging _SlashWorker
/ PTY child processes
Those are Python, not Node — use the
python-debugpy
skill for them. Only Node portions (Ink UI, tui_gateway client, tsx-run tests under
ui-tui/
) use this skill.
Running Vitest Tests Under the Debugger
cd /home/bb/hermes-agent/ui-tui
# Run a single test file paused on entry
node --inspect-brk ./node_modules/vitest/vitest.mjs run --no-file-parallelism src/app/foo.test.tsx
In another terminal:
node inspect -p <pid>
, then
sb('src/app/foo.tsx', 42)
,
cont
.
Use
--no-file-parallelism
(vitest) or
--runInBand
(jest) so only one worker exists — debugging a pool is painful.
Heap Snapshots & CPU Profiles (Non-interactive)
From the CDP driver above, swap Debugger for
HeapProfiler
/
Profiler
:
// CPU profile for 5 seconds
await client.Profiler.enable();
await client.Profiler.start();
await new Promise(r => setTimeout(r, 5000));
const { profile } = await client.Profiler.stop();
require('fs').writeFileSync('/tmp/cpu.cpuprofile', JSON.stringify(profile));
// Open /tmp/cpu.cpuprofile in Chrome DevTools → Performance tab
// Heap snapshot
await client.HeapProfiler.enable();
const chunks = [];
client.HeapProfiler.addHeapSnapshotChunk(({ chunk }) => chunks.push(chunk));
await client.HeapProfiler.takeHeapSnapshot({ reportProgress: false });
require('fs').writeFileSync('/tmp/heap.heapsnapshot', chunks.join(''));
Common Pitfalls
-
Wrong line numbers in TS source. Breakpoints hit the emitted JS, not the
.ts
. Either (a) break in the built dist/*.js
, or (b) enable sourcemaps (node --enable-source-maps
) and use sb('src/app.tsx', N)
— but only with CDP clients that follow sourcemaps. node inspect
CLI does not.
-
--inspect
vs --inspect-brk
.
--inspect
starts the inspector but doesn't pause; your script races past your first breakpoint if you attach too late. Use --inspect-brk
when you need to set breakpoints before any code runs.
-
Port collisions. Default is
9229
. If multiple Node processes are inspecting, pass --inspect=0
(random port) and read the actual URL from /json/list
:
curl -s http://127.0.0.1:9229/json/list # lists all inspectable targets on the host
-
Child processes.
--inspect
on a parent does NOT inspect its children. Use NODE_OPTIONS='--inspect-brk' node parent.js
to propagate to every child; be aware they all need unique ports (Node auto-increments when NODE_OPTIONS='--inspect'
is inherited).
-
Background kills. If you
Ctrl+C
out of node inspect
while the target is paused, the target stays paused. Either cont
first, or kill
the target explicitly.
-
Running node inspect
through an agent terminal. It's a PTY-friendly REPL. In Hermes, launch it with
terminal(pty=true)
or background=true
+ process(action='submit', data='...')
. Non-PTY foreground mode will work for one-shot commands but not for interactive stepping.
-
Security.
--inspect=0.0.0.0:9229
exposes arbitrary code execution. Always bind to 127.0.0.1
(the default) unless you have an isolated network.
Verification Checklist
After setting up a debug session, verify:
One-Shot Recipes
"Why is this variable undefined at line X?"
node --inspect-brk script.js &
node inspect -p $!
# debug>
sb('script.js', X)
cont
# paused. Now:
repl
> myVariable
> Object.keys(this)
"What's the call path into this function?"
debug> sb('suspectFn')
debug> cont
# paused on entry
debug> bt
"This async chain hangs — where?"
# Start with --inspect (no -brk), let it run to the hang, then:
debug> pause
debug> bt
# Now you see the stuck frame