packages/omo-codex/plugin/skills/debugging/references/runtimes/node.md
Covers Node 18+, tsx, ts-node, Bun, Deno. Launch recipes, inspector protocol usage, the node inspect CLI, and the tsx source-map silent-failure that costs people days.
node --version
cat package.json | head -40
# Which JS runtime launches the app? (order them; the first match wins)
ls node_modules/.bin/tsx 2>/dev/null && echo 'has tsx'
ls node_modules/.bin/ts-node 2>/dev/null && echo 'has ts-node'
ls node_modules/.bin/vitest 2>/dev/null && echo 'has vitest'
which bun 2>/dev/null && bun --version
which deno 2>/dev/null && deno --version
# Source-map situation
grep -E '"sourceMap"|"inlineSources"' tsconfig.json 2>/dev/null
grep -l '//# sourceMappingURL' dist/*.js 2>/dev/null | head -3
# Debug-relevant ports
lsof -iTCP:9229 -sTCP:LISTEN -nP 2>/dev/null
lsof -iTCP:9230 -sTCP:LISTEN -nP 2>/dev/null
node inspect CLI silent-failure (READ THIS)tsx transpiles each .ts file on the fly and emits an inline source map. V8 Inspector registers the module with its .ts path (so it shows up in the debugger's scripts list), but the node inspect CLI REPL does not resolve source-map line numbers reliably. Setting sb('session.ts', 285) will show a "pending" breakpoint that never fires even after the module loads.
The breakpoint list will happily display it, so you think it's set. It isn't.
| Workaround | When to use | Downside |
|---|---|---|
debugger; statement in source | You can edit the source, CLI required | Requires source edit + revert |
Chrome DevTools GUI (chrome://inspect) | CLI not required, faster iteration | Not usable if user specifically asked for CLI |
Debug the built dist/ JS | Source maps are working end-to-end | Requires npm run build on every source change |
The debugger; statement is the most reliable. Journal the edit — revert at Phase 9.
# Break on first line, wait for debugger to attach
node --inspect-brk=9229 dist/index.js
# Attach immediately, don't block startup — pair with debugger; statements
node --inspect=9229 dist/index.js
# Wait for debugger to attach, THEN run (new in Node 20.15+)
node --inspect-wait=9229 dist/index.js
# Source maps in stack traces (always a good idea in debug builds)
node --enable-source-maps --inspect dist/index.js
# The tsx runner is --import-compatible, so these work:
node --inspect-brk=9229 --import tsx index.ts
node --inspect=9229 --import tsx index.ts
# If user prefers invoking tsx directly, this also works but is less explicit:
NODE_OPTIONS='--inspect-brk=9229' npx tsx index.ts
# ⚠️ tsx watch + inspector = inspector reloads per file change
# Debug without watch:
node --inspect=9229 --import tsx index.ts # (no `watch`)
node --inspect-brk -r ts-node/register src/index.ts
# ESM (ts-node's ESM loader is fragile — if possible, migrate to tsx):
node --inspect --loader ts-node/esm src/index.ts
bun --inspect src/index.ts # opens debug.bun.sh URL
bun --inspect-brk src/index.ts # break on start
bun --inspect-wait src/index.ts # wait for attach
bun test --inspect-brk # debug test runner
Critical: Bun uses WebKit Inspector Protocol, not V8. chrome://inspect cannot connect directly. Use debug.bun.sh or the (currently buggy, per Bun docs) VS Code extension.
deno run --inspect-brk --allow-all src/main.ts
deno test --inspect-brk --filter "auth"
Deno is the smoothest TS debugging experience — native V8 inspector, no source-map workarounds.
# Single worker required — inspector can't attach to multiple workers
vitest --inspect-brk --no-file-parallelism
vitest --inspect-brk --browser --no-file-parallelism # browser mode
Without --no-file-parallelism, breakpoints won't fire because the process Vitest spawns workers in isn't the one listening on the inspector port.
node inspect CLInode inspect 127.0.0.1:9229 # attach to an existing --inspect process
Core commands at the debug> prompt:
cont, c resume until next break / debugger;
next, n step over
step, s step into
out, o step out
pause pause a running process
bt backtrace
scripts list all modules V8 has loaded (incl. tsx-transpiled .ts)
sb(N) set breakpoint at line N of current file
sb('file', N) set breakpoint at line N of matching file (⚠️ unreliable with tsx)
sb(func) set breakpoint at function reference
cb(N), cb('file', N) clear breakpoint
breakpoints list breakpoints (shows pending ones, doesn't tell you they'll never fire)
watch('expr') persistent watch expression
watchers show watchers
exec('expr') evaluate expression in paused frame's scope
repl drop into full REPL with frame's scope
restart restart the debuggee
kill kill the debuggee
exec('expr') is the most powerful tool in this CLI — it evaluates any JS in the paused frame and returns the value. Use it heavily.
exec() patterns that resolve hypotheses fastAt a breakpoint, these queries resolve most LLM / agent / async bugs in one line each:
// Agent / LLM state
exec('this.agent.state.messages.length')
exec('this.agent.state.messages.map(m => m.role)')
exec('JSON.stringify(this.agent.state.messages.at(-1)).substring(0, 500)')
exec('this.agent.state.messages.at(-1).errorMessage') // silent-error sentinel
exec('this.agent.state.messages.at(-1).stopReason')
exec('JSON.stringify(this.agent.state.usage)') // undefined / all-zero = failed call
exec('this.agent.state.model.baseUrl') // catch hardcoded vs env-var
// Env / config at runtime
exec('process.env.RELEVANT_VAR')
exec('Object.keys(process.env).filter(k => k.startsWith("ANTHROPIC"))')
exec('this.config')
// Async / timing
exec('Date.now() - this._turnStartedAt')
exec('this._activePromises?.size')
// HTTP request/response in-flight
exec('JSON.stringify(req.body).length')
exec('res.statusCode')
exec('res.headersSent')
// What's actually running
exec('process.version')
exec('process.cwd()')
exec('process.argv')
These are the patterns that most commonly look like success but aren't. Always check when a response is "too fast" or "too empty":
| Signal | What it means |
|---|---|
HTTP 200 + content: "" | Silent error swallowed |
| HTTP 200 + response in <1s for an LLM call | Too fast for a real Claude/GPT call; something short-circuited |
usage: { totalTokens: 0 } | LLM SDK returned a stub without making the call |
stopReason: "error" + content: [] | SDK packaged an error into a "success" message |
| Unhandled promise rejection with no log | Caller forgot to await, or .catch(() => {}) |
try { await x(); } catch {} | Error eaten, no log |
void somePromise() | Explicit opt-out of error propagation; often a bug |
| Callback-style API where callback never fires | Error happened before callback scheduled |
Handler returns res.json(...) twice | Second call is silent on some Express versions |
When you find one, add a temporary console.error('[DEBUG]', ...) to make it loud — journal it, revert at Phase 9.
# Long-running inspected process
tmux new-session -d -s debug-server -c "$PWD"
tmux send-keys -t debug-server 'node --inspect=9229 --import tsx index.ts' Enter
# Interactive debugger client (separate pane for readability)
tmux new-session -d -s debug-client -c "$PWD"
tmux send-keys -t debug-client 'node inspect 127.0.0.1:9229' Enter
# Non-blocking pane inspection from the outside
tmux capture-pane -p -t debug-server -S -50
Journal both session names. Kill both at Phase 9:
tmux kill-session -t debug-server
tmux kill-session -t debug-client
The user's preference for CLI is valid and should be respected. But you may recommend a switch in one short sentence if ANY of these hold:
sb('file', line) not firing) AND the fix is time-sensitivePhrase as a note, not a request: "I can push through with debugger; statements in CLI. If we hit three or more of these in a row, switching to chrome://inspect GUI would cut cycle time in half — your call."
# Revert source-level debug statements
git diff | grep -E '(debugger;|console\.log\(.*DEBUG|\[ARBITER-DEBUG|\[DEBUG)'
# Revert any matching files:
git checkout <file>
# Kill inspector-attached processes
pkill -f 'node --inspect' || true
pkill -f 'bun --inspect' || true
pkill -f 'deno.*--inspect' || true
lsof -iTCP:9229 -sTCP:LISTEN -nP 2>/dev/null