Skip to content

Instantly share code, notes, and snippets.

@ollieatkinson
Last active June 16, 2026 13:35
Show Gist options
  • Select an option

  • Save ollieatkinson/96792c780e6297d112ba17dfbc60c2fd to your computer and use it in GitHub Desktop.

Select an option

Save ollieatkinson/96792c780e6297d112ba17dfbc60c2fd to your computer and use it in GitHub Desktop.
~/.lldbinit plugin for claude / codex

lldb-ai

Ask Claude or Codex a debugging question from inside lldb. It gathers the current debug context, runs lldb commands against the live, stopped session to investigate, and answers — non-interactively, no prompts.

(lldb) claude what is the pointer value of bar?
(lldb) p self.bar
(MVVM.Bar?) 0x00006000000081a0
The pointer value of `bar` is 0x00006000000081a0.

Install

Needs macOS lldb with Python and the claude and/or codex CLI installed.

mkdir -p ~/.lldb-ai
curl -fsSL <gist-raw-url>/lldb_ai.py -o ~/.lldb-ai/lldb_ai.py
echo 'command script import ~/.lldb-ai/lldb_ai.py' >> ~/.lldbinit

Restart your lldb session (or Xcode debugging) to load it.

Usage

Stop at a breakpoint, then ask:

(lldb) claude why is self.items empty here?
(lldb) codex what does register x0 point to?

The model inspects the live session and the project source, then answers in the console. Each question auto-includes the current frame, source, locals, backtrace, registers, and threads.

Adding another agent

Append an Agent(...) to the AGENTS list in lldb_ai.py — it registers as a new lldb command on the next load:

Agent(
    "gemini", "gemini", ["/opt/homebrew/bin/gemini"],
    lambda exe, prompt, root: [exe, "--yolo", "-p", prompt],
),
"""LLDB AI plugins: `claude` and `codex`.
Ask an AI a question from inside a paused lldb session. The plugin gathers the
surrounding debug context, hands the question to the CLI, and lets the model
inspect the *live* session by running lldb commands through a file-based bridge.
The bridge uses request/response files (not a socket) because sandboxed CLIs
such as `codex --sandbox workspace-write` block socket connections but allow
file I/O within the workspace.
Install: add this to ~/.lldbinit
command script import ~/.lldb-ai/lldb_ai.py
"""
import glob
import os
import re
import shutil
import subprocess
import tempfile
import threading
import time
import lldb
TIMEOUT = 180 # seconds before the CLI subprocess is killed
# Commands run to build the context snapshot sent with every question.
CONTEXT_CMDS = [
("Current frame", "frame info"),
("Source around stop", "source list"),
("Locals & arguments", "frame variable"),
("Backtrace", "bt"),
("Registers", "register read"),
("Threads", "thread list"),
]
PREAMBLE = """You are running NON-INTERACTIVELY inside a PAUSED lldb debugging \
session. The user invoked you from the lldb prompt. You CANNOT ask the user \
questions or request permissions or confirmations — gather what you need and \
answer directly and concisely.
REPLY IN PLAIN TEXT ONLY. The lldb console does not render Markdown, so never \
use **bold**, `backticks`, headings, tables, or "- " bullet syntax — they show \
up as literal characters. Write short prose; if you must enumerate, use plain \
lines or "1." numbered points.
To inspect the live, stopped session, run lldb commands through the helper:
lldb_eval "<lldb command>"
for example: lldb_eval "po self.bar" or lldb_eval "frame variable"
Each call executes the command against the LIVE session and prints its output. \
Use it as many times as you need before answering. Prefer read-only inspection; \
do not resume or mutate the process (continue, step, expression with side \
effects) unless the user explicitly asks.
You also have read access to the project source (your working directory is the \
project root) — consult the code when it helps.
Useful lldb commands:
frame variable [name] locals/args without evaluating, no side effects (alias: v)
po <expr> print object description (Swift / Obj-C)
p <expr> evaluate and print a value (expression <expr> for full control)
expression -l swift -- <code> force Swift evaluation
bt / bt all backtrace of current thread / all threads
frame select <n> / up / down move between stack frames
thread list / thread select <n> list / switch threads
register read [name] register values
memory read <addr> / x <addr> read memory at an address
image lookup -a <addr> symbolicate an address
image lookup -rn <name> find a symbol by (regex) name
A snapshot of the current debug context follows, then the user's question."""
# Helper the model calls. It writes the command to a request file and waits for
# the matching response file. The bridge dir is baked in, so it needs no env.
# Writes are atomic (temp name + rename) so the watcher never sees a partial file.
HELPER_TEMPLATE = '''#!/usr/bin/env python3
import os, sys, time, tempfile
BRIDGE = __BRIDGE__
cmd = " ".join(sys.argv[1:])
fd, path = tempfile.mkstemp(prefix="t", dir=BRIDGE)
with os.fdopen(fd, "w") as f:
f.write(cmd)
req = path + ".req"
os.rename(path, req)
resp = req[:-4] + ".resp"
deadline = time.time() + 150
while time.time() < deadline:
if os.path.exists(resp):
with open(resp) as f:
sys.stdout.write(f.read())
try:
os.remove(resp)
except OSError:
pass
sys.exit(0)
time.sleep(0.05)
sys.stderr.write("lldb_eval: timed out waiting for the debugger\\n")
sys.exit(1)
'''
def _strip_markdown(text):
"""Flatten Markdown the lldb console renders as literal noise.
Models habitually wrap identifiers in backticks and bold despite being
told not to, so the final answer is cleaned deterministically rather than
trusting the prompt. `__name__`-style symbols are left intact.
"""
lines = []
for line in text.splitlines():
if line.lstrip().startswith("```"): # drop code fences
continue
line = re.sub(r"^\s*#{1,6}\s+", "", line) # headings
line = re.sub(r"^(\s*)[-*]\s+", r"\1- ", line) # normalise bullets
lines.append(line)
text = "\n".join(lines)
text = text.replace("**", "").replace("`", "") # bold + inline code
return text
def _run_lldb(debugger, cmd):
ret = lldb.SBCommandReturnObject()
debugger.GetCommandInterpreter().HandleCommand(cmd, ret)
out = ret.GetOutput() or ""
err = ret.GetError() or ""
return (out + err).rstrip() or "(no output)"
def _gather_context(debugger):
parts = []
for label, cmd in CONTEXT_CMDS:
parts.append("--- %s (`%s`) ---\n%s" % (label, cmd, _run_lldb(debugger, cmd)))
return "\n\n".join(parts)
def _detect_project_root(exe_ctx):
"""Git toplevel of the current frame's source file (language/tool agnostic).
Falls back to the source file's directory, then lldb's cwd, when the file
isn't in a git repo. Used as the CLI's working directory (and codex's
writable root under workspace-write).
"""
start = None
frame = exe_ctx.GetFrame()
if frame and frame.IsValid():
le = frame.GetLineEntry()
if le.IsValid():
directory = le.GetFileSpec().GetDirectory()
if directory:
start = directory
if not start:
start = os.getcwd()
try:
top = subprocess.run(
["git", "-C", start, "rev-parse", "--show-toplevel"],
capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL,
)
if top.returncode == 0 and top.stdout.strip():
return top.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return start # not a git repo — use the source file's directory
def _resolve(name, fallbacks):
found = shutil.which(name)
if found:
return found
for path in fallbacks:
if os.path.exists(path):
return path
return name
class Agent:
"""A non-interactive AI CLI exposed as an lldb command.
To add an agent, append an `Agent(...)` to `AGENTS` below — nothing else
needs to change.
name lldb command name (e.g. "claude").
binary executable name to resolve on PATH.
fallbacks absolute paths tried if `binary` isn't on PATH.
build_args fn(exe, prompt, root) -> argv (list). Must invoke the CLI
non-interactively with no permission/approval prompts.
bridge_in_root when True the bridge socket + `lldb_eval` helper are placed
inside the project root rather than a system temp dir. Set
this for agents whose sandbox confines filesystem/socket
access to the workspace (e.g. codex --sandbox workspace-write).
"""
def __init__(self, name, binary, fallbacks, build_args, bridge_in_root=False):
self.name = name
self.binary = binary
self.fallbacks = fallbacks
self.build_args = build_args
self.bridge_in_root = bridge_in_root
def argv(self, prompt, root):
return self.build_args(_resolve(self.binary, self.fallbacks), prompt, root)
AGENTS = [
Agent(
"claude", "claude", [os.path.expanduser("~/.local/bin/claude")],
lambda exe, prompt, root: [
exe, "-p", prompt,
"--add-dir", root,
"--allowedTools", "Read", "Grep", "Glob", "Bash(lldb_eval:*)",
],
),
Agent(
"codex", "codex", ["/opt/homebrew/bin/codex", "/usr/local/bin/codex"],
lambda exe, prompt, root: [
exe, "exec",
"--sandbox", "workspace-write",
"--skip-git-repo-check",
"-C", root,
prompt,
],
bridge_in_root=True,
),
]
def _serve(debugger, bridge, done, result):
"""Watch the bridge dir for request files, run them, write responses.
Runs on lldb's main thread so all SBDebugger calls stay single-threaded.
Keeps draining for one final pass after `done` so a request that landed
just before the CLI exited is still answered.
"""
while True:
finished = done.is_set()
for req in sorted(glob.glob(os.path.join(bridge, "*.req"))):
try:
with open(req) as fh:
cmd = fh.read()
os.remove(req)
except OSError:
continue
output = _run_lldb(debugger, cmd)
result.AppendMessage("(lldb) " + cmd)
result.AppendMessage(output)
resp = req[:-4] + ".resp"
tmp = resp + ".tmp"
try:
with open(tmp, "w") as fh:
fh.write(output)
os.rename(tmp, resp) # atomic: helper never reads a partial file
except OSError:
pass
if finished:
return
time.sleep(0.05)
def _run(debugger, agent, question, exe_ctx, result):
if not question.strip():
result.SetError("usage: %s <question>" % agent.name)
return
context = _gather_context(debugger)
root = _detect_project_root(exe_ctx)
prompt = "%s\n\n=== DEBUG CONTEXT ===\n%s\n\n=== QUESTION ===\n%s" % (
PREAMBLE, context, question.strip(),
)
if agent.bridge_in_root:
base = os.path.join(root, ".lldb_ai_bridge")
os.makedirs(base, exist_ok=True)
bridge = tempfile.mkdtemp(prefix="b_", dir=base)
else:
bridge = tempfile.mkdtemp(prefix="lldb_ai_")
state = {}
done = threading.Event()
try:
helper = os.path.join(bridge, "lldb_eval")
with open(helper, "w") as fh:
fh.write(HELPER_TEMPLATE.replace("__BRIDGE__", repr(bridge)))
os.chmod(helper, 0o755)
env = dict(os.environ)
env["PATH"] = bridge + os.pathsep + env.get("PATH", "")
argv = agent.argv(prompt, root)
def worker():
try:
proc = subprocess.run(
argv, cwd=root, env=env,
capture_output=True, text=True, timeout=TIMEOUT,
stdin=subprocess.DEVNULL, # CLIs block on inherited stdin otherwise
)
state["stdout"] = proc.stdout
state["stderr"] = proc.stderr
state["rc"] = proc.returncode
except subprocess.TimeoutExpired:
state["error"] = "timed out after %ds" % TIMEOUT
except FileNotFoundError:
state["error"] = "%r not found on PATH" % argv[0]
except Exception as exc: # noqa: BLE001 - surface anything, never crash lldb
state["error"] = str(exc)
finally:
done.set()
thread = threading.Thread(target=worker)
thread.start()
_serve(debugger, bridge, done, result)
thread.join()
if state.get("error"):
result.SetError(state["error"])
return
answer = _strip_markdown((state.get("stdout") or "").strip())
if answer:
result.AppendMessage("\n" + answer)
if state.get("rc"):
stderr = (state.get("stderr") or "").strip()
result.AppendMessage("\n[exit %s] %s" % (state["rc"], stderr))
finally:
shutil.rmtree(bridge, ignore_errors=True)
if agent.bridge_in_root:
try:
os.rmdir(os.path.dirname(bridge)) # remove .lldb_ai_bridge if now empty
except OSError:
pass
def _make_handler(agent):
def handler(debugger, command, exe_ctx, result, internal_dict):
_run(debugger, agent, command, exe_ctx, result)
return handler
def __lldb_init_module(debugger, internal_dict):
for agent in AGENTS:
fn_name = "_cmd_" + agent.name
globals()[fn_name] = _make_handler(agent)
debugger.HandleCommand(
"command script add --overwrite -f %s.%s %s" % (__name__, fn_name, agent.name)
)
names = ", ".join(a.name for a in AGENTS)
print("LLDB AI plugins loaded (%s) — usage: <agent> <question>" % names)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment