commit c068fbd58e731dfc96bb1fbf7da7408e3459dbc3 Author: Agent Date: Sat Oct 10 21:30:02 2026 +0000 Initial commit: omp-debug DSH plugin (oh-my-pi port) with docs and tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2f1f9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.bak +*.orig +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9d9132 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# omp-debug + +[oh-my-pi](https://github.com/can1357/oh-my-pi)'s `debug` tool for the DeepSeek Harness (DSH). The agent drives a **real debugger** over the Debug Adapter Protocol: it sets breakpoints, launches or attaches, steps, inspects stack and variables, and evaluates expressions in the stopped frame. No more print debugging. + +| Adapter | Languages | Install | +| --- | --- | --- | +| `debugpy` | Python | `pip install --user debugpy` | +| `node` (js-debug) | JavaScript / TypeScript (Node) | download `js-debug-dap-*.tar.gz` from [vscode-js-debug releases](https://github.com/microsoft/vscode-js-debug/releases) and extract to `~/.local/share/js-debug/` (or set `JS_DEBUG_DAP` to `dapDebugServer.js`) | +| `lldb-dap` | C, C++, Rust, Swift | `apt install lldb` | +| `gdb` | C, C++, Rust, Go, Fortran | `apt install gdb` (≥ 14, for `--interpreter=dap`) | +| `dlv` | Go | `go install github.com/go-delve/delve/cmd/dlv@latest` | + +The adapter is picked from the program's extension (`.py` → debugpy, `.js/.ts` → node, `.go`/Go module → dlv, native binaries → lldb-dap then gdb), or forced with `adapter`. `debug {"action":"adapters"}` shows what's installed. + +## The `debug` tool + +Typical flow: + +``` +debug {"action":"set_breakpoint","file":"app.py","line":12} # queued before launch +debug {"action":"launch","program":"app.py","args":["--x"]} +→ Stopped (breakpoint) thread 1 in handle at app.py:12 (frame 2) + 11: data = load(path) + →12: return transform(data) +debug {"action":"scopes"} # locals with values, [ref N] for nested +debug {"action":"evaluate","expression":"len(data)"} +debug {"action":"step_over"} / step_in / step_out / continue +debug {"action":"terminate"} +``` + +| Action | Notes | +| --- | --- | +| `launch` | `program`, `args`, `cwd`, `env`, `stop_on_entry`, `adapter` | +| `attach` | `pid`, or `host` + `port` (e.g. `python -m debugpy --listen 5678 app.py`) | +| `set_breakpoint` / `remove_breakpoint` | `file` + `line`, or `function`; `condition`, `hit_condition`, `log_message` (logpoint). Can be set before launch | +| `continue` / `step_over` / `step_in` / `step_out` / `pause` | Wait (up to `timeout`) for the next stop and show the location with source context and new program output | +| `stack_trace`, `threads`, `scopes`, `variables` (`variable_ref`), `evaluate` (`expression`, `frame_id`, `context`) | Inspection | +| `output` | Program stdout/stderr captured so far | +| `disassemble`, `read_memory`, `modules`, `loaded_sources`, `custom_request` | Low-level / adapter-specific | +| `sessions`, `adapters`, `terminate` | Management | + +There's one active session at a time. Child debug targets (e.g. js-debug's per-process sessions) are followed automatically. + +## Installation + +```bash +dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/omp-debug.git +``` + +Or in the Web UI, go to **Settings → Plugins → Install**. For development, use `link:` to a clone. Restart DSH afterwards. + +## Configuration + +```yaml +- id: omp-debug + config: + stopWaitMs: 8000 # how long launch waits for the first stop + maxOutputChars: 8000 # program output shown per result +``` + +## Tests + +`npm test` runs live sessions against debugpy and js-debug when they're installed. diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..70d5afb --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,6 @@ +- insert: + - id: omp-debug + name: '@local/omp-debug' + config: + stopWaitMs: 8000 + maxOutputChars: 8000 diff --git a/index.js b/index.js new file mode 100644 index 0000000..709a801 --- /dev/null +++ b/index.js @@ -0,0 +1,281 @@ +/** + * @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) } + }, + }) +} diff --git a/lib/dap.js b/lib/dap.js new file mode 100644 index 0000000..9cbad64 --- /dev/null +++ b/lib/dap.js @@ -0,0 +1,158 @@ +// Debug Adapter Protocol client (stdio or TCP transport, Content-Length framing). +import { spawn } from 'node:child_process' +import { EventEmitter } from 'node:events' +import net from 'node:net' + +export class DapClient extends EventEmitter { + constructor(name) { + super() + this.name = name + this.seq = 0 + this.pending = new Map() + this.buf = Buffer.alloc(0) + this.output = [] + this.outputBytes = 0 + this.closed = false + this.capabilities = {} + this.reverse = {} // command -> async handler(args) -> body + } + + /** stdio adapter */ + static spawnStdio(name, command, args, { cwd, env } = {}) { + const c = new DapClient(name) + c.proc = spawn(command, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) + c.proc.stdout.on('data', (d) => c.onData(d)) + c.proc.stderr.on('data', (d) => c.log('adapter', String(d))) + c.proc.on('exit', (code, sig) => c.close(`adapter exited (${sig ?? code})`)) + c.proc.on('error', (e) => c.close(e.message)) + c.write = (s) => { if (c.proc.stdin.writable) c.proc.stdin.write(s) } + return c + } + + /** TCP adapter: optionally spawn a server process, then connect (with retries). */ + static async connectTcp(name, { host = '127.0.0.1', port, spawnCmd, spawnArgs, cwd, env, timeoutMs = 15000 }) { + const c = new DapClient(name) + if (spawnCmd) { + c.proc = spawn(spawnCmd, spawnArgs, { cwd, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) + c.proc.stdout.on('data', (d) => c.log('adapter', String(d))) + c.proc.stderr.on('data', (d) => c.log('adapter', String(d))) + c.proc.on('exit', (code, sig) => c.close(`adapter exited (${sig ?? code})`)) + } + const deadline = Date.now() + timeoutMs + for (;;) { + try { + c.sock = await new Promise((resolve, reject) => { + const s = net.connect({ host, port }, () => resolve(s)) + s.once('error', reject) + }) + break + } catch (e) { + if (Date.now() > deadline || c.closed) { c.close(`connect ${host}:${port} failed: ${e.message}`); throw new Error(`${name}: could not connect to ${host}:${port} (${e.message})`) } + await new Promise((r) => setTimeout(r, 150)) + } + } + c.sock.on('data', (d) => c.onData(d)) + c.sock.on('close', () => c.close('socket closed')) + c.sock.on('error', () => {}) + c.write = (s) => { if (!c.sock.destroyed) c.sock.write(s) } + return c + } + + log(category, text) { + if (!text) return + this.output.push({ category, text, at: Date.now() }) + this.outputBytes += text.length + while (this.outputBytes > 200_000 && this.output.length > 1) this.outputBytes -= this.output.shift().text.length + } + + onData(chunk) { + this.buf = Buffer.concat([this.buf, chunk]) + for (;;) { + const sep = this.buf.indexOf('\r\n\r\n') + if (sep < 0) return + const m = /Content-Length:\s*(\d+)/i.exec(this.buf.subarray(0, sep).toString('ascii')) + if (!m) { this.buf = this.buf.subarray(sep + 4); continue } + const len = Number(m[1]) + if (this.buf.length < sep + 4 + len) return + const body = this.buf.subarray(sep + 4, sep + 4 + len).toString('utf8') + this.buf = this.buf.subarray(sep + 4 + len) + let msg + try { msg = JSON.parse(body) } catch { continue } + this.onMessage(msg) + } + } + + async onMessage(msg) { + if (msg.type === 'response') { + const p = this.pending.get(msg.request_seq) + if (!p) return + this.pending.delete(msg.request_seq) + clearTimeout(p.timer) + if (msg.success) p.resolve(msg.body ?? {}) + else p.reject(Object.assign(new Error(msg.body?.error?.format ?? msg.message ?? `${p.command} failed`), { body: msg.body })) + return + } + if (msg.type === 'event') { + if (msg.event === 'output') this.log(msg.body?.category ?? 'console', msg.body?.output ?? '') + this.emit('event', msg) + this.emit(msg.event, msg.body ?? {}) + return + } + if (msg.type === 'request') { + const h = this.reverse[msg.command] + try { + const body = h ? await h(msg.arguments ?? {}) : {} + this.send({ type: 'response', request_seq: msg.seq, success: !!h, command: msg.command, body, ...(h ? {} : { message: 'not supported' }) }) + } catch (e) { + this.send({ type: 'response', request_seq: msg.seq, success: false, command: msg.command, message: e.message }) + } + } + } + + send(obj) { + const s = JSON.stringify({ seq: ++this.seq, ...obj }) + this.write?.(`Content-Length: ${Buffer.byteLength(s, 'utf8')}\r\n\r\n${s}`) + return this.seq + } + + request(command, args, timeoutMs = 30000) { + if (this.closed) return Promise.reject(new Error(`${this.name}: session closed (${this.closeReason})`)) + return new Promise((resolve, reject) => { + const seq = this.send({ type: 'request', command, arguments: args }) + const timer = setTimeout(() => { this.pending.delete(seq); reject(new Error(`${this.name}: ${command} timed out after ${timeoutMs}ms`)) }, timeoutMs) + this.pending.set(seq, { resolve, reject, timer, command }) + }) + } + + /** Wait for an event, optionally filtered; resolves undefined on timeout. */ + waitFor(event, { timeoutMs = 10000, filter = () => true, signal } = {}) { + return new Promise((resolve) => { + const on = (body) => { if (filter(body)) { done(); resolve(body) } } + const onClose = () => { done(); resolve(undefined) } + const timer = setTimeout(() => { done(); resolve(undefined) }, timeoutMs) + const onAbort = () => { done(); resolve(undefined) } + const done = () => { clearTimeout(timer); this.off(event, on); this.off('closed', onClose); signal?.removeEventListener?.('abort', onAbort) } + this.on(event, on) + this.once('closed', onClose) + signal?.addEventListener?.('abort', onAbort, { once: true }) + }) + } + + close(reason = 'closed') { + if (this.closed) return + this.closed = true + this.closeReason = reason + for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new Error(`${this.name}: ${reason}`)) } + this.pending.clear() + try { this.sock?.destroy() } catch {} + // Kill the adapter (and anything it spawned) — a lingering adapter keeps the host's event loop busy. + const p = this.proc + if (p && p.exitCode === null && p.signalCode === null) { + try { process.kill(-p.pid, 'SIGTERM') } catch { try { p.kill('SIGTERM') } catch {} } + const t = setTimeout(() => { try { process.kill(-p.pid, 'SIGKILL') } catch { try { p.kill('SIGKILL') } catch {} } }, 1500) + t.unref?.() + } + for (const s of [p?.stdin, p?.stdout, p?.stderr]) { try { s?.destroy() } catch {} } + this.emit('closed', reason) + } +} diff --git a/lib/session.js b/lib/session.js new file mode 100644 index 0000000..6a3a9c5 --- /dev/null +++ b/lib/session.js @@ -0,0 +1,247 @@ +// Debug session manager: adapter catalog, launch/attach, stop tracking, child sessions. +import fs from 'node:fs' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { DapClient } from './dap.js' + +const HOME = os.homedir() + +function which(cmd) { + if (path.isAbsolute(cmd)) return fs.existsSync(cmd) ? cmd : null + for (const d of [...(process.env.PATH || '').split(path.delimiter), path.join(HOME, '.local/bin'), '/usr/local/bin']) { + if (!d) continue + const p = path.join(d, cmd) + try { fs.accessSync(p, fs.constants.X_OK); return p } catch {} + } + return null +} + +function pythonWithDebugpy() { + for (const py of ['python3', 'python']) { + const p = which(py) + if (!p) continue + try { execFileSync(p, ['-c', 'import debugpy'], { stdio: 'ignore', timeout: 10000 }); return p } catch {} + } + return null +} + +function jsDebugServer() { + const cands = [process.env.JS_DEBUG_DAP, path.join(HOME, '.local/share/js-debug/js-debug/src/dapDebugServer.js')].filter(Boolean) + return cands.find((c) => fs.existsSync(c)) ?? null +} + +const freePort = () => new Promise((resolve, reject) => { + const s = net.createServer() + s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)) }) + s.on('error', reject) +}) + +/** + * Adapter catalog. `detect()` returns spawn info or null; `launchArgs`/`attachArgs` build the + * DAP request arguments. + */ +export const ADAPTERS = { + debugpy: { + languages: ['python'], extensions: ['.py'], + detect: () => { const py = pythonWithDebugpy(); return py ? { kind: 'stdio', command: py, args: ['-m', 'debugpy.adapter'] } : null }, + install: 'pip install --user debugpy', + launchArgs: ({ program, args, cwd, env, stopOnEntry }) => ({ type: 'python', request: 'launch', program, args, cwd, env, stopOnEntry: !!stopOnEntry, console: 'internalConsole', justMyCode: false, redirectOutput: true }), + attachArgs: ({ host, port, pid }) => (pid ? { type: 'python', request: 'attach', processId: pid, justMyCode: false } : { type: 'python', request: 'attach', connect: { host: host ?? '127.0.0.1', port }, justMyCode: false }), + }, + node: { + languages: ['javascript', 'typescript'], extensions: ['.js', '.mjs', '.cjs', '.ts', '.mts'], + detect: () => { const s = jsDebugServer(); return s ? { kind: 'tcp-server', command: process.execPath, scriptArgs: (port) => [s, String(port), '127.0.0.1'] } : null }, + install: 'download js-debug-dap from github.com/microsoft/vscode-js-debug/releases into ~/.local/share/js-debug', + launchArgs: ({ program, args, cwd, env, stopOnEntry }) => ({ type: 'pwa-node', request: 'launch', program, args, cwd, env, stopOnEntry: !!stopOnEntry, console: 'internalConsole', outputCapture: 'std', runtimeExecutable: which('node') ?? process.execPath, skipFiles: ['/**'] }), + attachArgs: ({ host, port, pid }) => (pid ? { type: 'pwa-node', request: 'attach', processId: String(pid) } : { type: 'pwa-node', request: 'attach', address: host ?? '127.0.0.1', port }), + }, + 'lldb-dap': { + languages: ['c', 'cpp', 'rust', 'swift'], extensions: ['', '.out', '.bin'], + detect: () => { const b = which('lldb-dap') ?? which('lldb-vscode'); return b ? { kind: 'stdio', command: b, args: [] } : null }, + install: 'apt install lldb (provides lldb-dap)', + launchArgs: ({ program, args, cwd, env, stopOnEntry }) => ({ type: 'lldb-dap', request: 'launch', program, args, cwd, env: Object.entries(env ?? {}).map(([k, v]) => `${k}=${v}`), stopOnEntry: !!stopOnEntry }), + attachArgs: ({ pid }) => ({ type: 'lldb-dap', request: 'attach', pid }), + }, + gdb: { + languages: ['c', 'cpp', 'rust', 'go', 'fortran'], extensions: ['', '.out', '.bin'], + detect: () => { + const b = which('gdb') + if (!b) return null + try { const v = execFileSync(b, ['--version'], { encoding: 'utf8' }); const m = /(\d+)\.\d+/.exec(v); if (!m || Number(m[1]) < 14) return null } catch { return null } + return { kind: 'stdio', command: b, args: ['--interpreter=dap'] } + }, + install: 'apt install gdb (>= 14 has --interpreter=dap)', + launchArgs: ({ program, args, cwd, stopOnEntry }) => ({ request: 'launch', program, args, cwd, stopAtBeginningOfMainSubprogram: !!stopOnEntry }), + attachArgs: ({ pid }) => ({ request: 'attach', pid }), + }, + dlv: { + languages: ['go'], extensions: ['.go'], + detect: () => { const b = which('dlv'); return b ? { kind: 'tcp-server', command: b, scriptArgs: (port) => ['dap', '--listen', `127.0.0.1:${port}`] } : null }, + install: 'go install github.com/go-delve/delve/cmd/dlv@latest', + launchArgs: ({ program, args, cwd, env, stopOnEntry }) => ({ request: 'launch', mode: program.endsWith('.go') || fs.statSync(program).isDirectory() ? 'debug' : 'exec', program, args, cwd, env, stopOnEntry: !!stopOnEntry }), + attachArgs: ({ pid }) => ({ request: 'attach', mode: 'local', processId: pid }), + acceptsDirectoryProgram: true, + }, +} + +export function pickAdapter(program, forced) { + if (forced) { + if (!ADAPTERS[forced]) throw new Error(`unknown adapter ${forced}; available: ${Object.keys(ADAPTERS).join(', ')}`) + return forced + } + const ext = path.extname(program ?? '').toLowerCase() + if (['.py'].includes(ext)) return 'debugpy' + if (['.js', '.mjs', '.cjs', '.ts', '.mts'].includes(ext)) return 'node' + if (ext === '.go' || (program && fs.existsSync(program) && fs.statSync(program).isDirectory() && fs.existsSync(path.join(program, 'go.mod')))) return 'dlv' + // native binary: prefer lldb-dap, then gdb + for (const a of ['lldb-dap', 'gdb']) if (ADAPTERS[a].detect()) return a + return 'lldb-dap' +} + +export function adapterStatus() { + return Object.entries(ADAPTERS).map(([id, a]) => ({ id, available: !!a.detect(), languages: a.languages, install: a.install })) +} + +/** One debug session (root DAP connection plus any child sessions it spawns). */ +export class DebugSession { + constructor(id, adapterId) { + this.id = id + this.adapterId = adapterId + this.state = 'starting' + this.stopped = null // { reason, threadId, description, text } + this.breakpoints = new Map() // file -> [{line, condition, hitCondition, logMessage}] + this.functionBreakpoints = [] + this.client = null + this.children = [] + this.exitCode = undefined + } + + /** The client that currently owns execution (latest child with threads, else root). */ + get active() { return this.children.findLast((c) => !c.closed) ?? this.client } +} + +export class SessionManager { + constructor({ log = () => {} } = {}) { + this.sessions = new Map() + this.log = log + this.seq = 0 + } + + get current() { return [...this.sessions.values()].findLast((s) => s.state !== 'terminated') } + + async connect(adapterId, cwd, { reusePort } = {}) { + const a = ADAPTERS[adapterId] + const spec = a.detect() + if (!spec) throw new Error(`debug adapter ${adapterId} is not installed (${a.install}).`) + if (spec.kind === 'stdio') return DapClient.spawnStdio(adapterId, spec.command, spec.args, { cwd }) + // Child sessions (js-debug startDebugging) connect to the parent's server, not a new one. + if (reusePort) return DapClient.connectTcp(adapterId, { port: reusePort, cwd }) + const port = await freePort() + const c = await DapClient.connectTcp(adapterId, { port, spawnCmd: spec.command, spawnArgs: spec.scriptArgs(port), cwd }) + c.serverPort = port + return c + } + + wire(session, client, root = true) { + client.on('stopped', async (b) => { + session.state = 'stopped' + session.stopped = { reason: b.reason, threadId: b.threadId, description: b.description ?? b.text, allThreadsStopped: b.allThreadsStopped, hitBreakpointIds: b.hitBreakpointIds, client } + session.activeClient = client + }) + client.on('continued', () => { if (session.stopped?.client === client) { session.state = 'running'; session.stopped = null } }) + client.on('exited', (b) => { session.exitCode = b.exitCode }) + client.on('terminated', () => { + if (!root) return + session.state = 'terminated' + // The debuggee is gone: release adapters (children first, then the server-owning root). + setTimeout(() => { for (const c of [...session.children, session.client]) c?.close('debuggee terminated') }, 300).unref?.() + }) + client.on('closed', () => { if (root) session.state = 'terminated' }) + // js-debug: child targets arrive as startDebugging reverse requests. + client.reverse.startDebugging = async (args) => { + const child = await this.connect(session.adapterId, session.cwd, { reusePort: session.client.serverPort }) + session.children.push(child) + this.wire(session, child, false) + await child.request('initialize', initArgs(session.adapterId)) + const launched = child.request(args.request ?? 'launch', args.configuration ?? {}, 60000) + await this.syncBreakpoints(session, child) + await child.request('configurationDone', {}).catch(() => {}) + await launched.catch((e) => client.log('stderr', `child ${args.request}: ${e.message}\n`)) + return {} + } + client.reverse.runInTerminal = async (args) => { + const { spawn } = await import('node:child_process') + const p = spawn(args.args[0], args.args.slice(1), { cwd: args.cwd, env: { ...process.env, ...(args.env ?? {}) }, stdio: ['ignore', 'pipe', 'pipe'], detached: false }) + p.stdout.on('data', (d) => client.log('stdout', String(d))) + p.stderr.on('data', (d) => client.log('stderr', String(d))) + return { processId: p.pid } + } + } + + async start({ request, adapter, program, args = [], cwd, env, stopOnEntry, host, port, pid, timeoutMs = 30000, breakpoints = [] }) { + if (this.current) throw new Error(`a debug session is already active (${this.current.id}, ${this.current.adapterId}); terminate it first`) + const adapterId = pickAdapter(program, adapter ?? (request === 'attach' && port && !pid ? 'debugpy' : undefined)) + const id = `dbg${++this.seq}` + const s = new DebugSession(id, adapterId) + s.cwd = cwd + s.program = program + for (const bp of breakpoints) { + if (bp.function) s.functionBreakpoints.push({ name: bp.function, condition: bp.condition }) + else s.breakpoints.set(bp.file, [...(s.breakpoints.get(bp.file) ?? []).filter((b) => b.line !== bp.line), bp]) + } + this.sessions.set(id, s) + try { + s.client = await this.connect(adapterId, cwd) + this.wire(s, s.client, true) + s.capabilities = await s.client.request('initialize', initArgs(adapterId), timeoutMs) + const initialized = s.client.waitFor('initialized', { timeoutMs }) + const a = ADAPTERS[adapterId] + const reqArgs = request === 'attach' ? a.attachArgs({ host, port, pid }) : a.launchArgs({ program, args, cwd, env, stopOnEntry }) + const started = s.client.request(request, reqArgs, timeoutMs * 2) + started.catch(() => {}) + await initialized + await this.syncBreakpoints(s, s.client) + if (s.capabilities?.supportsConfigurationDoneRequest !== false) await s.client.request('configurationDone', {}, timeoutMs).catch(() => {}) + await started + if (s.state === 'starting') s.state = 'running' + return s + } catch (e) { + s.client?.close('start failed') + s.state = 'terminated' + throw e + } + } + + async syncBreakpoints(s, client) { + for (const [file, bps] of s.breakpoints) { + const r = await client.request('setBreakpoints', { source: { path: file }, breakpoints: bps.map((b) => ({ line: b.line, ...(b.condition ? { condition: b.condition } : {}), ...(b.hitCondition ? { hitCondition: b.hitCondition } : {}), ...(b.logMessage ? { logMessage: b.logMessage } : {}) })) }).catch((e) => ({ error: e.message })) + s.lastBreakpointResult = r + } + if (s.functionBreakpoints.length && client.capabilities?.supportsFunctionBreakpoints !== false) { + await client.request('setFunctionBreakpoints', { breakpoints: s.functionBreakpoints.map((f) => ({ name: f.name, ...(f.condition ? { condition: f.condition } : {}) })) }).catch(() => {}) + } + } + + async stopAll() { + for (const s of this.sessions.values()) { + for (const c of [...s.children, s.client]) { + if (!c || c.closed) continue + await c.request('disconnect', { terminateDebuggee: true }, 3000).catch(() => {}) + c.close('terminated') + } + s.state = 'terminated' + } + } +} + +export function initArgs(adapterId) { + return { + clientID: 'dsh-omp-debug', clientName: 'DSH omp-debug', adapterID: adapterId, locale: 'en', + linesStartAt1: true, columnsStartAt1: true, pathFormat: 'path', + supportsVariableType: true, supportsVariablePaging: true, supportsRunInTerminalRequest: true, + supportsMemoryReferences: true, supportsProgressReporting: false, supportsInvalidatedEvent: true, + supportsMemoryEvent: false, supportsStartDebuggingRequest: true, supportsArgsCanBeInterpretedByShell: false, + } +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..60a88c4 --- /dev/null +++ b/locale/en.json @@ -0,0 +1 @@ +{ "meta": { "title": "OMP Debug", "description": "debug tool: breakpoints, stepping, stack, variables and evaluate through real debuggers (debugpy, js-debug, lldb-dap, gdb, dlv)." } } diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..962ad02 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1 @@ +{ "meta": { "title": "OMP 调试", "description": "debug 工具:通过真实调试器(debugpy、js-debug、lldb-dap、gdb、dlv)设置断点、单步、查看栈与变量、求值。" } } diff --git a/package.json b/package.json new file mode 100644 index 0000000..78b6ac0 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "@local/omp-debug", + "version": "1.0.0", + "description": "oh-my-pi style debug tool for DSH: drive real debuggers over DAP (debugpy, js-debug, lldb-dap, gdb, dlv).", + "private": true, + "type": "module", + "license": "MIT", + "exports": { ".": "./index.js", "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" }, + "files": ["index.js", "lib/*.js", "cordis.patch.yml", "locale/*.json"], + "scripts": { "test": "node --test test/*.test.mjs" }, + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +} diff --git a/test/debug.test.mjs b/test/debug.test.mjs new file mode 100644 index 0000000..44cbfe2 --- /dev/null +++ b/test/debug.test.mjs @@ -0,0 +1,70 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import * as plugin from '../index.js' +import { ADAPTERS } from '../lib/session.js' + +function harness() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'omp-debug-')) + const tools = new Map() + const effects = [] + const ctx = { tools: { register(d) { tools.set(d.name, d); return () => {} } }, effect(fn) { effects.push(fn()) } } + plugin.apply(ctx, { stopWaitMs: 15000 }) + const exec = { agent: { session: { header: { cwd: dir } } } } + const call = async (args) => (await tools.get('debug').execute(args, exec)).text + return { dir, call, cleanup: async () => { await call({ action: 'terminate' }).catch(() => {}); fs.rmSync(dir, { recursive: true, force: true }) } } +} + +test('adapters lists availability', async () => { + const h = harness() + const t = await h.call({ action: 'adapters' }) + assert.match(t, /debugpy/) + await h.cleanup() +}) + +test('python: breakpoint, inspect, evaluate, step, continue to exit', { skip: !ADAPTERS.debugpy.detect(), timeout: 60000 }, async () => { + const h = harness() + fs.writeFileSync(path.join(h.dir, 'p.py'), 'def add(a, b):\n c = a + b\n return c\n\nx = add(2, 3)\nprint("result", x)\n') + assert.match(await h.call({ action: 'set_breakpoint', file: 'p.py', line: 2 }), /queued/) + const l = await h.call({ action: 'launch', program: 'p.py' }) + assert.match(l, /Stopped \(breakpoint\).* in add at .*p\.py:2/s) + assert.match(l, /→2: c = a \+ b/) + const sc = await h.call({ action: 'scopes' }) + assert.match(sc, /a: int = 2/) + assert.equal((await h.call({ action: 'evaluate', expression: 'a * 10' })).split(' ')[0], '20') + const st = await h.call({ action: 'step_over' }) + assert.match(st, /p\.py:3/) + const tr = await h.call({ action: 'stack_trace' }) + assert.match(tr, /#0 add/) + const c = await h.call({ action: 'continue' }) + assert.match(c, /terminated/i) + assert.match(c, /result 5/) + assert.doesNotMatch(c, /Traceback|NoMoreMessages|E\+\d{5}/) + await h.cleanup() +}) + +test('python: terminate a stopped session cleanly', { skip: !ADAPTERS.debugpy.detect(), timeout: 60000 }, async () => { + const h = harness() + fs.writeFileSync(path.join(h.dir, 't.py'), 'import time\nfor i in range(100):\n time.sleep(0.05)\nprint("done")\n') + await h.call({ action: 'set_breakpoint', file: 't.py', line: 3 }) + assert.match(await h.call({ action: 'launch', program: 't.py' }), /Stopped \(breakpoint\)/) + const t = await h.call({ action: 'terminate' }) + assert.match(t, /^Session dbg\d+ terminated\./) + assert.doesNotMatch(t, /Traceback|NoMoreMessages|E\+\d{5}|couldn't handle/) + assert.match(await h.call({ action: 'sessions' }), /terminated/) + await h.cleanup() +}) + +test('node: breakpoint and evaluate via js-debug', { skip: !ADAPTERS.node.detect(), timeout: 90000 }, async () => { + const h = harness() + fs.writeFileSync(path.join(h.dir, 'n.js'), 'function mul(a, b) {\n const r = a * b\n return r\n}\nconsole.log("got", mul(6, 7))\n') + await h.call({ action: 'set_breakpoint', file: 'n.js', line: 3 }) + const l = await h.call({ action: 'launch', program: 'n.js' }) + assert.match(l, /Stopped.*n\.js:3/s, l) + assert.match(await h.call({ action: 'evaluate', expression: 'r + 1' }), /^43/) + const c = await h.call({ action: 'continue' }) + assert.match(c, /got 42|terminated|running/i) + await h.cleanup() +})