1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
//! node-proto backend — V8 Inspector transport.
//!
//! Same target surface as `node-inspect` but uses the Inspector
//! WebSocket protocol directly (see `crate::inspector`). Coexists
//! with the PTY-based node-inspect until the validation matrix
//! confirms parity; we only retire the PTY version once this one
//! passes all 5/5 cases across example projects.
use std::sync::OnceLock;
use regex::Regex;
use serde_json::Value;
use super::canonical::{BreakLoc, CanonicalOps, HitEvent};
use super::{Backend, CleanResult, Dependency, DependencyCheck, SpawnConfig};
pub struct NodeProtoBackend;
impl Backend for NodeProtoBackend {
fn name(&self) -> &'static str {
"node-proto"
}
fn description(&self) -> &'static str {
"Node.js via V8 Inspector protocol (structured events, separate stdout)"
}
fn types(&self) -> &'static [&'static str] {
&["node-proto", "node", "nodejs", "js", "javascript", "ts", "typescript"]
}
fn spawn_config(&self, target: &str, _args: &[String]) -> anyhow::Result<SpawnConfig> {
// The real spawn is done by the inspector transport (spawns
// node --inspect-brk directly). This config is unused but the
// trait requires one; we fill in plausible values so anything
// that inspects it reads sensibly.
Ok(SpawnConfig {
bin: "node".into(),
args: vec!["--inspect-brk=127.0.0.1:0".into(), target.into()],
env: vec![],
init_commands: vec![],
})
}
fn prompt_pattern(&self) -> &str {
// Unused — the inspector transport doesn't read a prompt.
r"debug> "
}
fn dependencies(&self) -> Vec<Dependency> {
vec![Dependency {
name: "node",
check: DependencyCheck::Binary {
name: "node",
alternatives: &["node"],
version_cmd: Some(("node", &["--version"])),
},
install: "https://nodejs.org # or: nvm install --lts",
}]
}
fn format_breakpoint(&self, spec: &str) -> String {
if let Some((file, line)) = spec.rsplit_once(':') {
format!("sb('{file}', {line})")
} else if spec.chars().all(|c| c.is_ascii_digit()) {
format!("sb({spec})")
} else {
format!("sb('{spec}')")
}
}
fn run_command(&self) -> &'static str {
"cont"
}
fn quit_command(&self) -> &'static str {
".exit"
}
fn help_command(&self) -> &'static str {
// Static because the protocol transport doesn't proxy a help
// command from the target.
"help"
}
fn parse_help(&self, _raw: &str) -> String {
"node-proto: cont, step, next, out, backtrace, breakpoints, \
sb(file, line), print <expr>, .exit"
.into()
}
fn adapters(&self) -> Vec<(&'static str, &'static str)> {
vec![("javascript.md", include_str!("../../skills/adapters/javascript.md"))]
}
fn canonical_ops(&self) -> Option<&dyn CanonicalOps> {
Some(self)
}
fn clean(&self, _cmd: &str, output: &str) -> CleanResult {
// The inspector transport produces structured text already —
// no banner noise to strip.
CleanResult {
output: output.to_string(),
events: vec![],
}
}
/// Hook: tells the daemon that this backend wants a protocol
/// transport rather than the default PTY. The daemon branches
/// on this in `run_daemon`.
fn uses_inspector(&self) -> bool {
true
}
}
impl CanonicalOps for NodeProtoBackend {
fn tool_name(&self) -> &'static str {
"node-proto"
}
fn auto_capture_locals(&self) -> bool {
// The inspector transport implements `locals` natively via
// Runtime.getProperties. The daemon's auto-capture path after
// each hit is safe here — there's no PTY state to disturb,
// and the roundtrip is a single JSON-RPC call per scope.
true
}
fn op_breaks(&self) -> anyhow::Result<String> {
Ok("breakpoints".into())
}
fn op_break(&self, loc: &BreakLoc) -> anyhow::Result<String> {
Ok(match loc {
BreakLoc::FileLine { file, line } => format!("sb('{file}', {line})"),
BreakLoc::Fqn(name) => format!("sb('{name}')"),
BreakLoc::ModuleMethod { module, method } => format!("sb('{module}:{method}')"),
})
}
fn op_break_conditional(&self, loc: &BreakLoc, cond: &str) -> anyhow::Result<String> {
// The inspector transport sniffs the trailing ` if <expr>` and
// feeds it to `Debugger.setBreakpointByUrl.condition`.
Ok(format!("{} if {cond}", self.op_break(loc)?))
}
fn op_pause(&self) -> anyhow::Result<String> {
Ok("pause".into())
}
fn op_catch(&self, filters: &[String]) -> anyhow::Result<String> {
// Inspector accepts "none" | "uncaught" | "all". The transport
// translates `caught`/`uncaught`/`all` tokens onto those states.
Ok(if filters.is_empty() {
"catch off".into()
} else {
format!("catch {}", filters.join(" "))
})
}
fn op_frame(&self, n: u32) -> anyhow::Result<String> {
// Inspector uses parenthesized `frame(N)` in its REPL flavour.
Ok(format!("frame({n})"))
}
fn op_set(&self, lhs: &str, rhs: &str) -> anyhow::Result<String> {
// V8 Inspector's `Debugger.setVariableValue` requires a scope
// number + variable name, not an arbitrary LHS expression. For
// now we surface the same native `set` syntax and let the
// transport fall back to `evaluate` with an assignment
// expression — V8 happily runs `x = 5` as an expression.
Ok(format!("set {lhs} = {rhs}"))
}
fn op_list(&self, loc: Option<&str>) -> anyhow::Result<String> {
// The transport's `list` reads the top frame's current
// line by default. An optional `file:line` argument retargets
// to a specific location — passed through verbatim.
Ok(match loc {
Some(s) => format!("list {s}"),
None => "list".into(),
})
}
fn op_watch(&self, expr: &str) -> anyhow::Result<String> {
Ok(format!("watch('{expr}')"))
}
/// Hit parsing over text output is never exercised for this
/// backend — the inspector transport delivers structured
/// `Debugger.paused` events that the daemon consumes via
/// `pending_hit()`. Provided only so the trait is complete; a
/// best-effort regex matches the text we emit on failure paths.
fn parse_hit(&self, output: &str) -> Option<HitEvent> {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r"paused at (\S+):(\d+)").unwrap());
for line in output.lines() {
if let Some(c) = re.captures(line) {
return Some(HitEvent {
location_key: format!("{}:{}", &c[1], &c[2]),
thread: None,
frame_symbol: None,
file: Some(c[1].to_string()),
line: c[2].parse().ok(),
});
}
}
None
}
fn parse_locals(&self, output: &str) -> Option<Value> {
// The inspector transport already emits a JSON object as the
// `locals` response — just round-trip it.
serde_json::from_str(output.trim()).ok()
}
}