282 lines
18 KiB
JavaScript
282 lines
18 KiB
JavaScript
/**
|
|
* @local/omp-debug — oh-my-pi's `debug` tool for DSH: drive a real debugger over the Debug
|
|
* Adapter Protocol (debugpy for Python, js-debug for Node/TypeScript, lldb-dap or gdb for native
|
|
* code, dlv for Go). One active session per host; execution actions wait for the next stop and
|
|
* report where the program stopped with source context.
|
|
*/
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { SessionManager, adapterStatus } from './lib/session.js'
|
|
|
|
export const name = 'omp-debug'
|
|
export const inject = ['tools']
|
|
|
|
const ACTIONS = ['launch', 'attach', 'set_breakpoint', 'remove_breakpoint', 'continue', 'step_over', 'step_in', 'step_out', 'pause', 'evaluate', 'stack_trace', 'threads', 'scopes', 'variables', 'output', 'disassemble', 'read_memory', 'modules', 'loaded_sources', 'custom_request', 'terminate', 'sessions', 'adapters']
|
|
|
|
export function apply(ctx, config = {}) {
|
|
const cfg = { stopWaitMs: 8000, maxOutputChars: 8000, ...config }
|
|
const mgr = new SessionManager()
|
|
ctx.effect(() => () => { mgr.stopAll() }, 'omp-debug: sessions')
|
|
|
|
const cwdOf = (exec) => exec?.agent?.session?.header?.cwd ?? process.cwd()
|
|
const need = () => {
|
|
const s = mgr.current
|
|
if (!s) throw new Error('No active debug session. Start one with action=launch (program=…) or action=attach.')
|
|
return s
|
|
}
|
|
|
|
async function where(s, { context = 3 } = {}) {
|
|
const c = s.stopped?.client ?? s.active
|
|
if (s.state === 'terminated') return `Session ${s.id} terminated${s.exitCode !== undefined ? ` (exit code ${s.exitCode})` : ''}.`
|
|
if (s.state !== 'stopped' || !s.stopped) return `Session ${s.id} is running.`
|
|
const threadId = s.stopped.threadId ?? (await c.request('threads', {}).catch(() => ({ threads: [] }))).threads?.[0]?.id
|
|
const st = await c.request('stackTrace', { threadId, startFrame: 0, levels: 1 }).catch(() => null)
|
|
const f = st?.stackFrames?.[0]
|
|
s.frameId = f?.id
|
|
s.threadId = threadId
|
|
const head = `Stopped (${s.stopped.reason}${s.stopped.description ? `: ${s.stopped.description}` : ''}) thread ${threadId}`
|
|
if (!f) return head
|
|
const src = f.source?.path ?? f.source?.name ?? '?'
|
|
const lines = []
|
|
if (f.source?.path && fs.existsSync(f.source.path)) {
|
|
const all = fs.readFileSync(f.source.path, 'utf8').split('\n')
|
|
for (let n = Math.max(1, f.line - context); n <= Math.min(all.length, f.line + context); n++) lines.push(`${n === f.line ? '→' : ' '}${n}:${all[n - 1]}`)
|
|
}
|
|
return [`${head} in ${f.name} at ${src}:${f.line}${f.column ? `:${f.column}` : ''} (frame ${f.id})`, ...lines].join('\n')
|
|
}
|
|
|
|
/** Run an execution action and wait (bounded) for the program to stop or end. */
|
|
async function runAndWait(s, fn, waitMs) {
|
|
const c = s.stopped?.client ?? s.active
|
|
const before = s.stopped
|
|
s.state = 'running'; s.stopped = null
|
|
try { await fn(c) } catch (e) { s.stopped = before; s.state = before ? 'stopped' : s.state; throw e }
|
|
const deadline = Date.now() + waitMs
|
|
while (Date.now() < deadline && s.state === 'running') await new Promise((res) => setTimeout(res, 50))
|
|
await new Promise((res) => setTimeout(res, 50))
|
|
const r = s.state !== 'running'
|
|
if (!r) return `Still running after ${Math.round(waitMs / 1000)}s (use action=pause, or continue/output later).\n${tail(s)}`
|
|
return `${await where(s)}${tail(s, true)}`
|
|
}
|
|
|
|
function tail(s, onlyNew = false) {
|
|
const all = [s.client, ...s.children].filter(Boolean).flatMap((c) => c.output)
|
|
const fresh = all.filter((o) => !onlyNew || !o.seen)
|
|
for (const o of fresh) o.seen = true
|
|
// Program output only: adapter process logs (category 'adapter') and telemetry stay out.
|
|
const text = fresh.filter((o) => o.category !== 'telemetry' && o.category !== 'adapter').map((o) => o.text).join('')
|
|
if (!text.trim()) return ''
|
|
return `\n--- program output ---\n${text.length > cfg.maxOutputChars ? `…${text.slice(-cfg.maxOutputChars)}` : text}`
|
|
}
|
|
|
|
const fmtVar = (v) => `${v.name}${v.type ? `: ${v.type}` : ''} = ${String(v.value).length > 300 ? `${String(v.value).slice(0, 300)}…` : v.value}${v.variablesReference ? ` [ref ${v.variablesReference}]` : ''}`
|
|
|
|
async function run(args, exec) {
|
|
const a = args.action
|
|
const timeoutMs = Math.max(5, Math.min(300, args.timeout ?? 30)) * 1000
|
|
const cwd = args.cwd ? path.resolve(cwdOf(exec), args.cwd) : cwdOf(exec)
|
|
switch (a) {
|
|
case 'adapters':
|
|
return adapterStatus().map((x) => `${x.available ? '✓' : '✗'} ${x.id} (${x.languages.join(', ')})${x.available ? '' : ` — install: ${x.install}`}`).join('\n')
|
|
case 'sessions':
|
|
return [...mgr.sessions.values()].map((s) => `${s.id} ${s.adapterId} ${s.state}${s.program ? ` ${s.program}` : ''}`).join('\n') || 'No sessions.'
|
|
case 'launch': case 'attach': {
|
|
const program = args.program ? path.resolve(cwd, args.program) : undefined
|
|
if (a === 'launch' && !program) throw new Error('program is required for launch')
|
|
if (program && !fs.existsSync(program)) throw new Error(`program not found: ${program}`)
|
|
const s = await mgr.start({ request: a, adapter: args.adapter, program, args: args.args ?? [], cwd, env: args.env, stopOnEntry: args.stop_on_entry, host: args.host, port: args.port, pid: args.pid, timeoutMs, breakpoints: pendingBreakpoints.splice(0) })
|
|
// Wait until the program stops (any session in the tree), ends, or the budget runs out.
|
|
const deadline = Date.now() + cfg.stopWaitMs
|
|
while (Date.now() < deadline && s.state !== 'stopped' && s.state !== 'terminated') await new Promise((res) => setTimeout(res, 50))
|
|
return `Session ${s.id} started with ${s.adapterId}.\n${await where(s)}${tail(s, true)}`
|
|
}
|
|
case 'set_breakpoint': case 'remove_breakpoint': {
|
|
const s = mgr.current
|
|
if (args.function) {
|
|
if (!s) { if (a === 'set_breakpoint') pendingBreakpoints.push({ function: args.function, condition: args.condition }); return `Function breakpoint on ${args.function} ${a === 'set_breakpoint' ? 'queued for the next launch' : 'removed'}.` }
|
|
s.functionBreakpoints = s.functionBreakpoints.filter((f) => f.name !== args.function)
|
|
if (a === 'set_breakpoint') s.functionBreakpoints.push({ name: args.function, condition: args.condition })
|
|
await mgr.syncBreakpoints(s, s.active)
|
|
return `Function breakpoints: ${s.functionBreakpoints.map((f) => f.name).join(', ') || 'none'}`
|
|
}
|
|
if (!args.file || !args.line) throw new Error('file and line (or function) are required')
|
|
const bp = { file: path.resolve(cwd, args.file), line: args.line, condition: args.condition, hitCondition: args.hit_condition, logMessage: args.log_message }
|
|
if (!s) {
|
|
if (a === 'set_breakpoint') pendingBreakpoints.push(bp)
|
|
else { const i = pendingBreakpoints.findIndex((p) => p.file === bp.file && p.line === bp.line); if (i >= 0) pendingBreakpoints.splice(i, 1) }
|
|
return `Breakpoint ${a === 'set_breakpoint' ? 'queued' : 'removed'} at ${args.file}:${args.line} (applied on next launch).`
|
|
}
|
|
if (a === 'remove_breakpoint') {
|
|
s.breakpoints.set(bp.file, (s.breakpoints.get(bp.file) ?? []).filter((b) => b.line !== bp.line))
|
|
await mgr.syncBreakpoints(s, s.active)
|
|
return `Removed breakpoint at ${args.file}:${args.line}.`
|
|
}
|
|
return addBreakpoint(s, bp)
|
|
}
|
|
case 'continue': { const s = need(); return runAndWait(s, (c) => c.request('continue', { threadId: s.threadId ?? s.stopped?.threadId ?? 1 }), timeoutMs) }
|
|
case 'step_over': case 'step_in': case 'step_out': {
|
|
const s = need()
|
|
if (s.state !== 'stopped') throw new Error('program is running; pause or wait for a breakpoint first')
|
|
const cmd = { step_over: 'next', step_in: 'stepIn', step_out: 'stepOut' }[a]
|
|
return runAndWait(s, (c) => c.request(cmd, { threadId: s.threadId ?? s.stopped.threadId }), timeoutMs)
|
|
}
|
|
case 'pause': {
|
|
const s = need()
|
|
const threads = (await s.active.request('threads', {})).threads ?? []
|
|
return runAndWait(s, (c) => c.request('pause', { threadId: args.thread_id ?? threads[0]?.id ?? 1 }), timeoutMs)
|
|
}
|
|
case 'evaluate': {
|
|
const s = need()
|
|
if (!args.expression) throw new Error('expression is required')
|
|
const c = s.stopped?.client ?? s.active
|
|
const r = await c.request('evaluate', { expression: args.expression, frameId: args.frame_id ?? s.frameId, context: args.context ?? 'repl' }, timeoutMs)
|
|
return `${r.result}${r.type ? ` (${r.type})` : ''}${r.variablesReference ? ` [ref ${r.variablesReference}]` : ''}`
|
|
}
|
|
case 'stack_trace': {
|
|
const s = need()
|
|
const c = s.stopped?.client ?? s.active
|
|
const r = await c.request('stackTrace', { threadId: args.thread_id ?? s.threadId ?? s.stopped?.threadId, startFrame: 0, levels: args.levels ?? 30 }, timeoutMs)
|
|
return (r.stackFrames ?? []).map((f, i) => `#${i} ${f.name} — ${f.source?.path ?? f.source?.name ?? '?'}:${f.line} (frame ${f.id})`).join('\n') || 'No frames.'
|
|
}
|
|
case 'threads': {
|
|
const s = need()
|
|
const r = await s.active.request('threads', {}, timeoutMs)
|
|
return (r.threads ?? []).map((t) => `${t.id} ${t.name}`).join('\n') || 'No threads.'
|
|
}
|
|
case 'scopes': {
|
|
const s = need()
|
|
const c = s.stopped?.client ?? s.active
|
|
const r = await c.request('scopes', { frameId: args.frame_id ?? s.frameId }, timeoutMs)
|
|
const out = []
|
|
for (const sc of r.scopes ?? []) {
|
|
out.push(`${sc.name} [ref ${sc.variablesReference}]${sc.expensive ? ' (expensive)' : ''}`)
|
|
if (!sc.expensive && out.length < 200) {
|
|
const v = await c.request('variables', { variablesReference: sc.variablesReference, count: 50 }, timeoutMs).catch(() => ({ variables: [] }))
|
|
for (const x of (v.variables ?? []).slice(0, 50)) out.push(` ${fmtVar(x)}`)
|
|
}
|
|
}
|
|
return out.join('\n') || 'No scopes.'
|
|
}
|
|
case 'variables': {
|
|
const s = need()
|
|
const ref = args.variable_ref ?? args.scope_id
|
|
if (!ref) throw new Error('variable_ref is required')
|
|
const c = s.stopped?.client ?? s.active
|
|
const r = await c.request('variables', { variablesReference: ref, start: args.start ?? 0, count: args.count ?? 100 }, timeoutMs)
|
|
return (r.variables ?? []).map(fmtVar).join('\n') || 'No variables.'
|
|
}
|
|
case 'output': {
|
|
const s = mgr.sessions.size ? [...mgr.sessions.values()].at(-1) : need()
|
|
const prog = tail(s, false).trim()
|
|
const adapterLog = [s.client, ...s.children].filter(Boolean).flatMap((c) => c.output).filter((o) => o.category === 'adapter').map((o) => o.text).join('').trim()
|
|
return [prog || 'No program output yet.', ...(args.context === 'adapter' && adapterLog ? ['--- adapter log ---', adapterLog.slice(-cfg.maxOutputChars)] : [])].join('\n')
|
|
}
|
|
case 'disassemble': {
|
|
const s = need()
|
|
const mem = args.memory_reference ?? (await s.active.request('stackTrace', { threadId: s.threadId, levels: 1 }).catch(() => null))?.stackFrames?.[0]?.instructionPointerReference
|
|
if (!mem) throw new Error('memory_reference is required (the adapter did not report an instruction pointer)')
|
|
const r = await s.active.request('disassemble', { memoryReference: mem, instructionOffset: args.instruction_offset ?? 0, instructionCount: args.instruction_count ?? 20, resolveSymbols: true }, timeoutMs)
|
|
return (r.instructions ?? []).map((i) => `${i.address} ${i.instruction}${i.symbol ? ` <${i.symbol}>` : ''}`).join('\n')
|
|
}
|
|
case 'read_memory': {
|
|
const s = need()
|
|
if (!args.memory_reference || !args.count) throw new Error('memory_reference and count are required')
|
|
const r = await s.active.request('readMemory', { memoryReference: args.memory_reference, count: args.count, offset: args.offset ?? 0 }, timeoutMs)
|
|
const bytes = Buffer.from(r.data ?? '', 'base64')
|
|
return `${r.address}: ${bytes.toString('hex').replace(/(..)/g, '$1 ').trim()}${r.unreadableBytes ? ` (+${r.unreadableBytes} unreadable)` : ''}`
|
|
}
|
|
case 'modules': { const s = need(); const r = await s.active.request('modules', {}, timeoutMs); return (r.modules ?? []).map((m) => `${m.id} ${m.name}${m.path ? ` ${m.path}` : ''}`).join('\n') || 'No modules.' }
|
|
case 'loaded_sources': { const s = need(); const r = await s.active.request('loadedSources', {}, timeoutMs); return (r.sources ?? []).map((x) => x.path ?? x.name).join('\n') || 'No sources.' }
|
|
case 'custom_request': {
|
|
const s = need()
|
|
if (!args.command) throw new Error('command is required')
|
|
const r = await s.active.request(args.command, args.arguments ? JSON.parse(args.arguments) : {}, timeoutMs)
|
|
return JSON.stringify(r, null, 1).slice(0, 20000)
|
|
}
|
|
case 'terminate': {
|
|
const s = mgr.current
|
|
if (!s) return 'No active debug session.'
|
|
for (const c of [...s.children, s.client]) {
|
|
if (!c || c.closed) continue
|
|
const graceful = c.capabilities?.supportsTerminateRequest || s.capabilities?.supportsTerminateRequest
|
|
if (graceful) {
|
|
await c.request('terminate', {}, 5000).catch(() => {})
|
|
await c.waitFor('terminated', { timeoutMs: 3000 })
|
|
}
|
|
if (!c.closed) await c.request('disconnect', { terminateDebuggee: true }, 3000).catch(() => {})
|
|
c.close('terminated')
|
|
}
|
|
s.state = 'terminated'
|
|
return `Session ${s.id} terminated.${tail(s, true)}`
|
|
}
|
|
default:
|
|
throw new Error(`unknown action ${a}`)
|
|
}
|
|
}
|
|
|
|
const pendingBreakpoints = []
|
|
async function addBreakpoint(s, bp) {
|
|
if (bp.function) {
|
|
s.functionBreakpoints.push({ name: bp.function, condition: bp.condition })
|
|
await mgr.syncBreakpoints(s, s.active)
|
|
return `Function breakpoint on ${bp.function}.`
|
|
}
|
|
const list = (s.breakpoints.get(bp.file) ?? []).filter((b) => b.line !== bp.line)
|
|
list.push(bp)
|
|
s.breakpoints.set(bp.file, list)
|
|
for (const c of [s.client, ...s.children].filter((x) => x && !x.closed)) await mgr.syncBreakpoints(s, c)
|
|
const res = s.lastBreakpointResult?.breakpoints?.find((b) => b.line === bp.line) ?? s.lastBreakpointResult?.breakpoints?.at(-1)
|
|
const verified = res?.verified === false ? ` (unverified${res.message ? `: ${res.message}` : ''})` : ''
|
|
return `Breakpoint at ${path.basename(bp.file)}:${res?.line ?? bp.line}${bp.condition ? ` if ${bp.condition}` : ''}${verified}.`
|
|
}
|
|
|
|
ctx.tools.register({
|
|
name: 'debug',
|
|
description: 'Drive a real debugger (DAP): debugpy for Python, js-debug for Node/TS, lldb-dap or gdb for C/C++/Rust, dlv for Go. Flow: set_breakpoint (file+line, or function; conditions, hit counts and logpoints supported; may be set before launch) → launch (program, args, stop_on_entry) or attach (pid, or host+port for debugpy) → when stopped: stack_trace, scopes, variables (variable_ref), evaluate (expression in the current frame) → continue / step_over / step_in / step_out (each waits for the next stop and shows the location with source) → terminate. Also: threads, pause, output, disassemble, read_memory, modules, loaded_sources, custom_request, sessions, adapters (list installed debuggers). One active session at a time.',
|
|
parameters: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['action'],
|
|
properties: {
|
|
action: { type: 'string', enum: ACTIONS },
|
|
program: { type: 'string', description: 'launch: script/binary/package to debug.' },
|
|
args: { type: 'array', items: { type: 'string' }, description: 'launch: program arguments.' },
|
|
cwd: { type: 'string', description: 'Working directory (defaults to the session workspace).' },
|
|
env: { type: 'object', additionalProperties: true, description: 'launch: extra environment variables.' },
|
|
adapter: { type: 'string', enum: ['debugpy', 'node', 'lldb-dap', 'gdb', 'dlv'], description: 'Force an adapter (auto-detected from program otherwise).' },
|
|
stop_on_entry: { type: 'boolean', description: 'launch: stop at the first line.' },
|
|
pid: { type: 'integer', description: 'attach: process id.' },
|
|
host: { type: 'string', description: 'attach: debug server host.' },
|
|
port: { type: 'integer', description: 'attach: debug server port (e.g. debugpy --listen).' },
|
|
file: { type: 'string', description: 'Breakpoint source file.' },
|
|
line: { type: 'integer', description: 'Breakpoint line (1-based).' },
|
|
function: { type: 'string', description: 'Function breakpoint name.' },
|
|
condition: { type: 'string', description: 'Breakpoint condition expression.' },
|
|
hit_condition: { type: 'string', description: 'Breakpoint hit-count condition.' },
|
|
log_message: { type: 'string', description: 'Logpoint message ({expr} interpolated) instead of stopping.' },
|
|
expression: { type: 'string', description: 'evaluate: expression.' },
|
|
context: { type: 'string', description: 'evaluate context: repl (default), watch, hover.' },
|
|
frame_id: { type: 'integer', description: 'Frame for evaluate/scopes (default: top frame of the stop).' },
|
|
thread_id: { type: 'integer' },
|
|
variable_ref: { type: 'integer', description: 'variables: reference from scopes/variables/evaluate.' },
|
|
scope_id: { type: 'integer' },
|
|
start: { type: 'integer' },
|
|
count: { type: 'integer', description: 'variables page size / read_memory byte count.' },
|
|
levels: { type: 'integer', description: 'stack_trace depth.' },
|
|
memory_reference: { type: 'string' },
|
|
instruction_count: { type: 'integer' },
|
|
instruction_offset: { type: 'integer' },
|
|
offset: { type: 'integer' },
|
|
command: { type: 'string', description: 'custom_request: DAP command.' },
|
|
arguments: { type: 'string', description: 'custom_request: JSON arguments.' },
|
|
timeout: { type: 'integer', description: 'Seconds to wait (5-300, default 30).' },
|
|
},
|
|
},
|
|
output: { schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } }, render: (_a, v) => [{ type: 'text', text: v.text }] },
|
|
timeoutMs: 320_000,
|
|
async execute(args, exec) {
|
|
return { text: await run(args ?? {}, exec) }
|
|
},
|
|
})
|
|
}
|