|
"""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) |