breakpoint-mcp 1.14.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/addon/breakpoint_mcp/bridge_secret.gd +61 -0
- package/addon/breakpoint_mcp/bridge_server.gd +66 -4
- package/addon/breakpoint_mcp/operations.gd +1 -1
- package/addon/breakpoint_mcp/pause_latch.gd +65 -0
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/plugin.gd +6 -0
- package/addon/breakpoint_mcp/runtime_bridge.gd +334 -5
- package/addon/breakpoint_mcp/status_dock.gd +48 -0
- package/dist/bridge.js +22 -1
- package/dist/capabilities.js +150 -0
- package/dist/cli/clients.js +5 -2
- package/dist/cli/doctor.js +41 -0
- package/dist/cli/init.js +48 -6
- package/dist/config.js +64 -0
- package/dist/confirm.js +21 -0
- package/dist/index.js +72 -65
- package/dist/pause.js +105 -0
- package/dist/recipes.js +168 -0
- package/dist/schemas.js +43 -0
- package/dist/secret.js +38 -0
- package/dist/tools/runtime.js +112 -0
- package/dist/toolsets.js +44 -0
- package/package.json +1 -1
|
@@ -22,7 +22,7 @@ var sink: Callable
|
|
|
22
22
|
func _log_message(message: String, error: bool) -> void:
|
|
23
23
|
if sink.is_valid():
|
|
24
24
|
sink.call("error" if error else "info", message)
|
|
25
|
-
func _log_error(
|
|
25
|
+
func _log_error(_function: String, file: String, line: int, code: String, rationale: String, _editor_notify: bool, error_type: int, _script_backtraces: Array) -> void:
|
|
26
26
|
if sink.is_valid():
|
|
27
27
|
var lvl := "warning" if error_type == 1 else "error"
|
|
28
28
|
var detail := rationale if rationale != "" else code
|
|
@@ -45,9 +45,17 @@ const MONITORS := {
|
|
|
45
45
|
"audio/output_latency": Performance.AUDIO_OUTPUT_LATENCY,
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const BridgeSecret := preload("res://addons/breakpoint_mcp/bridge_secret.gd")
|
|
49
|
+
const PauseLatch := preload("res://addons/breakpoint_mcp/pause_latch.gd")
|
|
50
|
+
|
|
48
51
|
var _server: TCPServer
|
|
49
52
|
var _clients: Array = [] # Array of {peer, buf}
|
|
50
53
|
var _port: int = DEFAULT_PORT
|
|
54
|
+
## Loopback-auth handshake state (default-on; see bridge_secret.gd), mirroring the
|
|
55
|
+
## editor bridge_server. `_auth_required` is false only when auth is explicitly
|
|
56
|
+
## disabled (BREAKPOINT_BRIDGE_INSECURE) or the secret can't be minted.
|
|
57
|
+
var _secret: String = ""
|
|
58
|
+
var _auth_required: bool = false
|
|
51
59
|
var _log: Array = [] # ring buffer of {seq, level, message}
|
|
52
60
|
var _log_seq: int = 0
|
|
53
61
|
var _tree_dirty: bool = false
|
|
@@ -62,6 +70,7 @@ func _ready() -> void:
|
|
|
62
70
|
var env_port := OS.get_environment("BREAKPOINT_RUNTIME_PORT")
|
|
63
71
|
if env_port != "" and env_port.is_valid_int():
|
|
64
72
|
_port = int(env_port)
|
|
73
|
+
_setup_auth()
|
|
65
74
|
_server = TCPServer.new()
|
|
66
75
|
var err := _server.listen(_port, "127.0.0.1")
|
|
67
76
|
if err != OK:
|
|
@@ -79,6 +88,22 @@ func _ready() -> void:
|
|
|
79
88
|
_install_log_capture()
|
|
80
89
|
|
|
81
90
|
|
|
91
|
+
## Establish the loopback-auth secret unless explicitly disabled. Default-on:
|
|
92
|
+
## BREAKPOINT_BRIDGE_INSECURE=1 (or =true) turns auth OFF (documented escape
|
|
93
|
+
## hatch). If the secret can't be persisted, run WITHOUT auth rather than
|
|
94
|
+
## bricking the bridge (a broken mint must not lock out the host).
|
|
95
|
+
func _setup_auth() -> void:
|
|
96
|
+
var insecure := OS.get_environment("BREAKPOINT_BRIDGE_INSECURE").to_lower()
|
|
97
|
+
if insecure == "1" or insecure == "true":
|
|
98
|
+
_auth_required = false
|
|
99
|
+
push_warning("[breakpoint_runtime] BREAKPOINT_BRIDGE_INSECURE set — loopback bridge auth DISABLED")
|
|
100
|
+
return
|
|
101
|
+
_secret = BridgeSecret.load_or_mint()
|
|
102
|
+
_auth_required = _secret != ""
|
|
103
|
+
if not _auth_required:
|
|
104
|
+
push_error("[breakpoint_runtime] could not establish bridge secret — running WITHOUT auth")
|
|
105
|
+
|
|
106
|
+
|
|
82
107
|
## Public API: game code can route its own logs here for runtime.get_log to read.
|
|
83
108
|
func push_log(level: String, message: String) -> void:
|
|
84
109
|
_log_seq += 1
|
|
@@ -119,7 +144,7 @@ func _process(_delta: float) -> void:
|
|
|
119
144
|
while _server.is_connection_available():
|
|
120
145
|
var peer := _server.take_connection()
|
|
121
146
|
if peer:
|
|
122
|
-
_clients.append({"peer": peer, "buf": ""})
|
|
147
|
+
_clients.append({"peer": peer, "buf": "", "authed": not _auth_required})
|
|
123
148
|
var alive: Array = []
|
|
124
149
|
for c in _clients:
|
|
125
150
|
var peer: StreamPeerTCP = c["peer"]
|
|
@@ -134,6 +159,10 @@ func _process(_delta: float) -> void:
|
|
|
134
159
|
var bytes: PackedByteArray = chunk[1]
|
|
135
160
|
c["buf"] += bytes.get_string_from_utf8()
|
|
136
161
|
_drain_lines(c)
|
|
162
|
+
if c.get("close", false):
|
|
163
|
+
peer.poll()
|
|
164
|
+
peer.disconnect_from_host()
|
|
165
|
+
continue
|
|
137
166
|
alive.append(c)
|
|
138
167
|
_clients = alive
|
|
139
168
|
# D3 follow-up: one runtime-tree push per frame if the SceneTree changed.
|
|
@@ -154,25 +183,55 @@ func _drain_lines(c: Dictionary) -> void:
|
|
|
154
183
|
var line := buf.substr(0, nl).strip_edges()
|
|
155
184
|
buf = buf.substr(nl + 1)
|
|
156
185
|
if line != "":
|
|
157
|
-
_handle_line(c
|
|
186
|
+
_handle_line(c, line)
|
|
187
|
+
if c.get("close", false):
|
|
188
|
+
break
|
|
158
189
|
c["buf"] = buf
|
|
159
190
|
|
|
160
191
|
|
|
161
|
-
func _handle_line(
|
|
192
|
+
func _handle_line(c: Dictionary, line: String) -> void:
|
|
193
|
+
var peer: StreamPeerTCP = c["peer"]
|
|
162
194
|
var parsed: Variant = JSON.parse_string(line)
|
|
163
195
|
if typeof(parsed) != TYPE_DICTIONARY:
|
|
196
|
+
if not c.get("authed", false):
|
|
197
|
+
_deny_unauth(c)
|
|
198
|
+
return
|
|
164
199
|
_send(peer, {"id": null, "ok": false, "error": {"code": "bad_json", "message": "Bad request"}})
|
|
165
200
|
return
|
|
166
201
|
var req: Dictionary = parsed
|
|
167
202
|
var id: Variant = req.get("id", null)
|
|
168
203
|
var method := String(req.get("method", ""))
|
|
169
204
|
var params: Dictionary = req.get("params", {}) if typeof(req.get("params")) == TYPE_DICTIONARY else {}
|
|
205
|
+
# Handshake gate: an unauthenticated peer may ONLY authenticate.
|
|
206
|
+
if not c.get("authed", false):
|
|
207
|
+
if method == "auth" and BridgeSecret.const_time_eq(String(params.get("secret", "")), _secret):
|
|
208
|
+
c["authed"] = true
|
|
209
|
+
_send(peer, {"id": id, "ok": true})
|
|
210
|
+
else:
|
|
211
|
+
_deny_unauth(c)
|
|
212
|
+
return
|
|
213
|
+
# Pause latch (addon "Pause Agent" control): honor the same editor-set latch the
|
|
214
|
+
# editor bridge does. While paused, HOLD every new command except a liveness
|
|
215
|
+
# `ping` — reject without dispatching; the running game is otherwise untouched.
|
|
216
|
+
# Read cross-process from res://.godot/ (the editor writes it; see pause_latch.gd).
|
|
217
|
+
if method != "ping" and PauseLatch.is_paused():
|
|
218
|
+
var held := {"id": id}
|
|
219
|
+
held.merge(PauseLatch.held_response(method))
|
|
220
|
+
_send(peer, held)
|
|
221
|
+
return
|
|
170
222
|
var result := _dispatch(method, params)
|
|
171
223
|
var response := {"id": id}
|
|
172
224
|
response.merge(result)
|
|
173
225
|
_send(peer, response)
|
|
174
226
|
|
|
175
227
|
|
|
228
|
+
## Generic, no-echo denial for an unauthenticated peer; marks the connection for
|
|
229
|
+
## closing. Never reveals the expected secret, the received value, or any detail.
|
|
230
|
+
func _deny_unauth(c: Dictionary) -> void:
|
|
231
|
+
_send(c["peer"], {"id": null, "ok": false, "error": {"code": "unauthorized"}})
|
|
232
|
+
c["close"] = true
|
|
233
|
+
|
|
234
|
+
|
|
176
235
|
func _send(peer: StreamPeerTCP, obj: Dictionary) -> void:
|
|
177
236
|
peer.put_data((JSON.stringify(obj) + "\n").to_utf8_buffer())
|
|
178
237
|
|
|
@@ -184,7 +243,7 @@ func _send(peer: StreamPeerTCP, obj: Dictionary) -> void:
|
|
|
184
243
|
func broadcast_event(uri: String) -> void:
|
|
185
244
|
for c in _clients:
|
|
186
245
|
var peer: StreamPeerTCP = c["peer"]
|
|
187
|
-
if peer and peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
|
246
|
+
if peer and c.get("authed", false) and peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
|
188
247
|
_send(peer, {"event": "resource.changed", "uri": uri})
|
|
189
248
|
|
|
190
249
|
|
|
@@ -255,6 +314,16 @@ func _dispatch(method: String, params: Dictionary) -> Dictionary:
|
|
|
255
314
|
return _screenshot()
|
|
256
315
|
"runtime.get_log":
|
|
257
316
|
return _get_log(params)
|
|
317
|
+
"runtime.assert_node_state":
|
|
318
|
+
return _assert_node_state(params)
|
|
319
|
+
"runtime.assert_scene_structure":
|
|
320
|
+
return _assert_scene_structure(params)
|
|
321
|
+
"runtime.assert_perf":
|
|
322
|
+
return _assert_perf(params)
|
|
323
|
+
"runtime.assert_screen_text":
|
|
324
|
+
return _assert_screen_text(params)
|
|
325
|
+
"runtime.screenshot_diff":
|
|
326
|
+
return _screenshot_diff(params)
|
|
258
327
|
_:
|
|
259
328
|
return _err("unknown_method", "No such method: %s" % method)
|
|
260
329
|
|
|
@@ -438,3 +507,263 @@ func _get_log(params: Dictionary) -> Dictionary:
|
|
|
438
507
|
if int(e["seq"]) > since and (levels.is_empty() or levels.has(e["level"])):
|
|
439
508
|
entries.append(e)
|
|
440
509
|
return _ok({"entries": entries, "latest_seq": _log_seq, "capture": _log_capture != null})
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
func _assert_node_state(params: Dictionary) -> Dictionary:
|
|
513
|
+
var node := _resolve(String(params.get("path", "")))
|
|
514
|
+
if node == null:
|
|
515
|
+
return _err("bad_path", "Node not found: %s" % params.get("path", ""))
|
|
516
|
+
var raw_expect: Variant = params.get("expect", {})
|
|
517
|
+
var expect: Dictionary = raw_expect if typeof(raw_expect) == TYPE_DICTIONARY else {}
|
|
518
|
+
var tol := float(params.get("tolerance", 0.0))
|
|
519
|
+
var mismatches: Array = []
|
|
520
|
+
for prop in expect.keys():
|
|
521
|
+
var key := String(prop)
|
|
522
|
+
var expected: Variant = expect[key]
|
|
523
|
+
var actual_encoded: Variant = Codec.encode(node.get(key))
|
|
524
|
+
if not _values_match(expected, actual_encoded, tol):
|
|
525
|
+
mismatches.append({"property": key, "expected": expected, "actual": actual_encoded})
|
|
526
|
+
return _ok({
|
|
527
|
+
"path": _path_of(node),
|
|
528
|
+
"ok": mismatches.is_empty(),
|
|
529
|
+
"checked": expect.size(),
|
|
530
|
+
"mismatches": mismatches,
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
func _assert_scene_structure(params: Dictionary) -> Dictionary:
|
|
535
|
+
var raw: Variant = params.get("expect", [])
|
|
536
|
+
var expect: Array = raw if typeof(raw) == TYPE_ARRAY else []
|
|
537
|
+
var failures: Array = []
|
|
538
|
+
for entry_v in expect:
|
|
539
|
+
if typeof(entry_v) != TYPE_DICTIONARY:
|
|
540
|
+
continue
|
|
541
|
+
var entry: Dictionary = entry_v
|
|
542
|
+
var path := String(entry.get("path", ""))
|
|
543
|
+
var absent := bool(entry.get("absent", false))
|
|
544
|
+
var node := _resolve(path)
|
|
545
|
+
if absent:
|
|
546
|
+
if node != null:
|
|
547
|
+
failures.append({"path": path, "reason": "expected_absent_but_present"})
|
|
548
|
+
continue
|
|
549
|
+
if node == null:
|
|
550
|
+
failures.append({"path": path, "reason": "missing"})
|
|
551
|
+
continue
|
|
552
|
+
if entry.has("type"):
|
|
553
|
+
var want_type := String(entry["type"])
|
|
554
|
+
if not node.is_class(want_type) and node.get_class() != want_type:
|
|
555
|
+
failures.append({"path": path, "reason": "type_mismatch", "expected": want_type, "actual": node.get_class()})
|
|
556
|
+
return _ok({
|
|
557
|
+
"ok": failures.is_empty(),
|
|
558
|
+
"checked": expect.size(),
|
|
559
|
+
"failures": failures,
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
func _values_match(expected: Variant, actual: Variant, tol: float) -> bool:
|
|
564
|
+
var te := typeof(expected)
|
|
565
|
+
var ta := typeof(actual)
|
|
566
|
+
var expected_num := te == TYPE_INT or te == TYPE_FLOAT
|
|
567
|
+
var actual_num := ta == TYPE_INT or ta == TYPE_FLOAT
|
|
568
|
+
if expected_num and actual_num:
|
|
569
|
+
return absf(float(actual) - float(expected)) <= tol
|
|
570
|
+
return _deep_equal(expected, actual)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
func _deep_equal(a: Variant, b: Variant) -> bool:
|
|
574
|
+
var ta := typeof(a)
|
|
575
|
+
var tb := typeof(b)
|
|
576
|
+
if ta != tb:
|
|
577
|
+
var an := ta == TYPE_INT or ta == TYPE_FLOAT
|
|
578
|
+
var bn := tb == TYPE_INT or tb == TYPE_FLOAT
|
|
579
|
+
if an and bn:
|
|
580
|
+
return float(a) == float(b)
|
|
581
|
+
return false
|
|
582
|
+
if ta == TYPE_DICTIONARY:
|
|
583
|
+
var da: Dictionary = a
|
|
584
|
+
var db: Dictionary = b
|
|
585
|
+
if da.size() != db.size():
|
|
586
|
+
return false
|
|
587
|
+
for k in da.keys():
|
|
588
|
+
if not db.has(k):
|
|
589
|
+
return false
|
|
590
|
+
if not _deep_equal(da[k], db[k]):
|
|
591
|
+
return false
|
|
592
|
+
return true
|
|
593
|
+
if ta == TYPE_ARRAY:
|
|
594
|
+
var aa: Array = a
|
|
595
|
+
var ba: Array = b
|
|
596
|
+
if aa.size() != ba.size():
|
|
597
|
+
return false
|
|
598
|
+
for i in aa.size():
|
|
599
|
+
if not _deep_equal(aa[i], ba[i]):
|
|
600
|
+
return false
|
|
601
|
+
return true
|
|
602
|
+
return a == b
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
func _assert_perf(params: Dictionary) -> Dictionary:
|
|
606
|
+
var raw_baseline: Variant = params.get("baseline", {})
|
|
607
|
+
var baseline: Dictionary = raw_baseline if typeof(raw_baseline) == TYPE_DICTIONARY else {}
|
|
608
|
+
var tol := float(params.get("tolerance", 0.0))
|
|
609
|
+
var raw_dir: Variant = params.get("direction", {})
|
|
610
|
+
var dir_overrides: Dictionary = raw_dir if typeof(raw_dir) == TYPE_DICTIONARY else {}
|
|
611
|
+
var regressions: Array = []
|
|
612
|
+
var monitors := {}
|
|
613
|
+
var checked := 0
|
|
614
|
+
for key_v in baseline.keys():
|
|
615
|
+
var key := String(key_v)
|
|
616
|
+
if not MONITORS.has(key):
|
|
617
|
+
continue
|
|
618
|
+
checked += 1
|
|
619
|
+
var current := float(Performance.get_monitor(MONITORS[key]))
|
|
620
|
+
var base_val := float(baseline[key])
|
|
621
|
+
monitors[key] = current
|
|
622
|
+
var direction := "higher_better" if key == "time/fps" else "lower_better"
|
|
623
|
+
if dir_overrides.has(key):
|
|
624
|
+
direction = String(dir_overrides[key])
|
|
625
|
+
var passed := true
|
|
626
|
+
if direction == "higher_better":
|
|
627
|
+
passed = current >= base_val * (1.0 - tol)
|
|
628
|
+
else:
|
|
629
|
+
passed = current <= base_val * (1.0 + tol)
|
|
630
|
+
if not passed:
|
|
631
|
+
regressions.append({"key": key, "baseline": base_val, "current": current, "direction": direction})
|
|
632
|
+
return _ok({
|
|
633
|
+
"ok": regressions.is_empty(),
|
|
634
|
+
"checked": checked,
|
|
635
|
+
"regressions": regressions,
|
|
636
|
+
"monitors": monitors,
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
func _text_of(node: Node) -> String:
|
|
641
|
+
# The visible text a Control exposes via its `text` property (Label / Button /
|
|
642
|
+
# LineEdit / TextEdit / RichTextLabel / CheckBox / LinkButton …). Non-text nodes
|
|
643
|
+
# return null from get() and are skipped.
|
|
644
|
+
var v: Variant = node.get("text")
|
|
645
|
+
if v is String:
|
|
646
|
+
return String(v)
|
|
647
|
+
return ""
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
func _assert_screen_text(params: Dictionary) -> Dictionary:
|
|
651
|
+
var needle := String(params.get("text", ""))
|
|
652
|
+
var present := bool(params.get("present", true))
|
|
653
|
+
var use_regex := bool(params.get("regex", false))
|
|
654
|
+
var case_sensitive := bool(params.get("case_sensitive", false))
|
|
655
|
+
var has_min := params.has("min_count")
|
|
656
|
+
var min_count := int(params.get("min_count", 0))
|
|
657
|
+
var re: RegEx = null
|
|
658
|
+
if use_regex:
|
|
659
|
+
re = RegEx.new()
|
|
660
|
+
var pattern := needle if case_sensitive else "(?i)" + needle
|
|
661
|
+
if re.compile(pattern) != OK:
|
|
662
|
+
return _err("bad_regex", "Invalid regex: %s" % needle)
|
|
663
|
+
var samples: Array = []
|
|
664
|
+
var count := 0
|
|
665
|
+
var base := _base()
|
|
666
|
+
if base != null:
|
|
667
|
+
var stack: Array = [base]
|
|
668
|
+
while not stack.is_empty():
|
|
669
|
+
var node: Node = stack.pop_back()
|
|
670
|
+
for child in node.get_children():
|
|
671
|
+
stack.append(child)
|
|
672
|
+
if not (node is CanvasItem):
|
|
673
|
+
continue
|
|
674
|
+
if not (node as CanvasItem).is_visible_in_tree():
|
|
675
|
+
continue
|
|
676
|
+
var txt := _text_of(node)
|
|
677
|
+
if txt == "":
|
|
678
|
+
continue
|
|
679
|
+
var matched := false
|
|
680
|
+
if use_regex:
|
|
681
|
+
matched = re.search(txt) != null
|
|
682
|
+
elif case_sensitive:
|
|
683
|
+
matched = txt.contains(needle)
|
|
684
|
+
else:
|
|
685
|
+
matched = txt.to_lower().contains(needle.to_lower())
|
|
686
|
+
if matched:
|
|
687
|
+
count += 1
|
|
688
|
+
if samples.size() < 20:
|
|
689
|
+
samples.append({"path": _path_of(node), "text": txt})
|
|
690
|
+
var ok := (count > 0) if present else (count == 0)
|
|
691
|
+
if present and has_min:
|
|
692
|
+
ok = count >= min_count
|
|
693
|
+
return _ok({
|
|
694
|
+
"ok": ok,
|
|
695
|
+
"matches": count,
|
|
696
|
+
"present": present,
|
|
697
|
+
"samples": samples,
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
func _screenshot_diff(params: Dictionary) -> Dictionary:
|
|
702
|
+
var reference := String(params.get("reference", ""))
|
|
703
|
+
var vp := get_viewport()
|
|
704
|
+
if vp == null:
|
|
705
|
+
return _err("no_viewport", "No viewport")
|
|
706
|
+
var tex := vp.get_texture()
|
|
707
|
+
if tex == null:
|
|
708
|
+
return _err("no_texture", "No viewport texture")
|
|
709
|
+
var img := tex.get_image()
|
|
710
|
+
if img == null:
|
|
711
|
+
return _err("no_image", "Could not read frame")
|
|
712
|
+
var ref_img := Image.new()
|
|
713
|
+
var err := ref_img.load(reference)
|
|
714
|
+
if err != OK:
|
|
715
|
+
return _err("bad_reference", "Could not load reference image: %s (error %d)" % [reference, err])
|
|
716
|
+
return _compare_images(img, ref_img, params, reference)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
func _compare_images(frame: Image, reference_img: Image, params: Dictionary, reference: String) -> Dictionary:
|
|
720
|
+
var tolerance := float(params.get("tolerance", 0.0))
|
|
721
|
+
var per_channel := int(params.get("per_channel_threshold", 0))
|
|
722
|
+
# Work on copies normalized to RGBA8 so get_data() is a byte-aligned comparison.
|
|
723
|
+
var a := Image.new()
|
|
724
|
+
a.copy_from(frame)
|
|
725
|
+
var b := Image.new()
|
|
726
|
+
b.copy_from(reference_img)
|
|
727
|
+
a.convert(Image.FORMAT_RGBA8)
|
|
728
|
+
b.convert(Image.FORMAT_RGBA8)
|
|
729
|
+
var region_v: Variant = params.get("region")
|
|
730
|
+
if region_v is Dictionary:
|
|
731
|
+
var rg: Dictionary = region_v
|
|
732
|
+
var rect := Rect2i(int(rg.get("x", 0)), int(rg.get("y", 0)), int(rg.get("w", 0)), int(rg.get("h", 0)))
|
|
733
|
+
a = a.get_region(rect)
|
|
734
|
+
b = b.get_region(rect)
|
|
735
|
+
var w := a.get_width()
|
|
736
|
+
var h := a.get_height()
|
|
737
|
+
if w != b.get_width() or h != b.get_height():
|
|
738
|
+
return _ok({
|
|
739
|
+
"ok": false,
|
|
740
|
+
"reason": "dimension_mismatch",
|
|
741
|
+
"diff_ratio": 1.0,
|
|
742
|
+
"differing_pixels": 0,
|
|
743
|
+
"total_pixels": 0,
|
|
744
|
+
"width": w,
|
|
745
|
+
"height": h,
|
|
746
|
+
"reference": reference,
|
|
747
|
+
})
|
|
748
|
+
var total := w * h
|
|
749
|
+
var da := a.get_data()
|
|
750
|
+
var db := b.get_data()
|
|
751
|
+
var differing := 0
|
|
752
|
+
var n := da.size()
|
|
753
|
+
var i := 0
|
|
754
|
+
while i < n:
|
|
755
|
+
for c in 4:
|
|
756
|
+
if absi(int(da[i + c]) - int(db[i + c])) > per_channel:
|
|
757
|
+
differing += 1
|
|
758
|
+
break
|
|
759
|
+
i += 4
|
|
760
|
+
var ratio := (float(differing) / float(total)) if total > 0 else 0.0
|
|
761
|
+
return _ok({
|
|
762
|
+
"ok": ratio <= tolerance,
|
|
763
|
+
"diff_ratio": ratio,
|
|
764
|
+
"differing_pixels": differing,
|
|
765
|
+
"total_pixels": total,
|
|
766
|
+
"width": w,
|
|
767
|
+
"height": h,
|
|
768
|
+
"reference": reference,
|
|
769
|
+
})
|
|
@@ -14,6 +14,8 @@ extends VBoxContainer
|
|
|
14
14
|
## thread never stalls. LSP/DAP ports are read from EditorSettings, so the dock
|
|
15
15
|
## reflects the user's actual configuration, not just the defaults.
|
|
16
16
|
|
|
17
|
+
const PauseLatch := preload("res://addons/breakpoint_mcp/pause_latch.gd")
|
|
18
|
+
|
|
17
19
|
const DEFAULT_BRIDGE_PORT := 9080
|
|
18
20
|
const DEFAULT_RUNTIME_PORT := 9081
|
|
19
21
|
const DEFAULT_LSP_PORT := 6005
|
|
@@ -25,6 +27,7 @@ const PROBE_DEADLINE_MSEC := 1500
|
|
|
25
27
|
const COLOR_OK := Color(0.38, 0.85, 0.45)
|
|
26
28
|
const COLOR_FAIL := Color(0.92, 0.47, 0.40)
|
|
27
29
|
const COLOR_PENDING := Color(0.62, 0.62, 0.62)
|
|
30
|
+
const COLOR_PAUSED := Color(0.95, 0.73, 0.30)
|
|
28
31
|
|
|
29
32
|
const PLANES := [
|
|
30
33
|
{"key": "editor", "name": "editor-bridge"},
|
|
@@ -75,6 +78,8 @@ var _probes := {} # plane key -> {peer, deadline, port}
|
|
|
75
78
|
var _config_label: Label = null
|
|
76
79
|
var _copy_feedback: Label = null
|
|
77
80
|
var _timer: Timer = null
|
|
81
|
+
var _pause_toggle: CheckButton = null
|
|
82
|
+
var _pause_state: Label = null
|
|
78
83
|
|
|
79
84
|
|
|
80
85
|
## plugin.gd injects the live bridge server so the editor-bridge plane is read
|
|
@@ -133,6 +138,27 @@ func _build_ui() -> void:
|
|
|
133
138
|
_set_plane(p["key"], "pending", "…")
|
|
134
139
|
add_child(HSeparator.new())
|
|
135
140
|
|
|
141
|
+
# Control: a one-click "Pause Agent" latch honored by the editor + runtime
|
|
142
|
+
# bridges (pause_latch.gd). Engaged, those two planes hold new agent commands
|
|
143
|
+
# until resumed — the visible companion to the host signal latch (whole-surface).
|
|
144
|
+
var ctrl_hdr := Label.new()
|
|
145
|
+
ctrl_hdr.text = "Control"
|
|
146
|
+
ctrl_hdr.modulate = Color(1, 1, 1, 0.75)
|
|
147
|
+
add_child(ctrl_hdr)
|
|
148
|
+
|
|
149
|
+
_pause_toggle = CheckButton.new()
|
|
150
|
+
_pause_toggle.text = "Pause Agent"
|
|
151
|
+
_pause_toggle.tooltip_text = "Hold new agent commands on the editor + runtime bridges. In-flight ops finish and a bare ping still answers; resume to continue."
|
|
152
|
+
_pause_toggle.toggled.connect(_on_pause_toggled)
|
|
153
|
+
add_child(_pause_toggle)
|
|
154
|
+
|
|
155
|
+
_pause_state = Label.new()
|
|
156
|
+
_pause_state.add_theme_font_size_override("font_size", 12)
|
|
157
|
+
_pause_state.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
158
|
+
add_child(_pause_state)
|
|
159
|
+
|
|
160
|
+
add_child(HSeparator.new())
|
|
161
|
+
|
|
136
162
|
var cfg_hdr := Label.new()
|
|
137
163
|
cfg_hdr.text = "Config"
|
|
138
164
|
cfg_hdr.modulate = Color(1, 1, 1, 0.75)
|
|
@@ -197,6 +223,7 @@ func _refresh() -> void:
|
|
|
197
223
|
_start_probe("lsp", _lsp_port())
|
|
198
224
|
_start_probe("dap", _dap_port())
|
|
199
225
|
_update_config_line()
|
|
226
|
+
_update_pause_ui()
|
|
200
227
|
|
|
201
228
|
|
|
202
229
|
func _update_editor_plane() -> void:
|
|
@@ -227,6 +254,27 @@ func _on_copy_pressed() -> void:
|
|
|
227
254
|
_copy_feedback.text = "Copied the mcpServers snippet to the clipboard."
|
|
228
255
|
|
|
229
256
|
|
|
257
|
+
func _on_pause_toggled(pressed: bool) -> void:
|
|
258
|
+
PauseLatch.set_paused(pressed)
|
|
259
|
+
_update_pause_ui()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
## Reflect the latch state in the toggle + label. Called on every refresh so an
|
|
263
|
+
## external change (plugin enable/disable clears it, or the host) shows here too;
|
|
264
|
+
## set_pressed_no_signal avoids re-firing `toggled` back into set_paused.
|
|
265
|
+
func _update_pause_ui() -> void:
|
|
266
|
+
if _pause_toggle == null or _pause_state == null:
|
|
267
|
+
return
|
|
268
|
+
var paused := PauseLatch.is_paused()
|
|
269
|
+
_pause_toggle.set_pressed_no_signal(paused)
|
|
270
|
+
if paused:
|
|
271
|
+
_pause_state.text = "PAUSED — editor + runtime bridges are holding new agent commands."
|
|
272
|
+
_pause_state.add_theme_color_override("font_color", COLOR_PAUSED)
|
|
273
|
+
else:
|
|
274
|
+
_pause_state.text = "Running — agent commands flow normally."
|
|
275
|
+
_pause_state.add_theme_color_override("font_color", COLOR_OK)
|
|
276
|
+
|
|
277
|
+
|
|
230
278
|
# --- ports -----------------------------------------------------------------
|
|
231
279
|
|
|
232
280
|
func _project_path() -> String:
|
package/dist/bridge.js
CHANGED
|
@@ -27,6 +27,7 @@ export class BridgeClient {
|
|
|
27
27
|
defaultTimeoutMs;
|
|
28
28
|
label;
|
|
29
29
|
hint;
|
|
30
|
+
secretProvider;
|
|
30
31
|
socket = null;
|
|
31
32
|
connecting = null;
|
|
32
33
|
buffer = "";
|
|
@@ -34,12 +35,21 @@ export class BridgeClient {
|
|
|
34
35
|
eventListeners = new Set();
|
|
35
36
|
wantConnected = false;
|
|
36
37
|
reconnectTimer = null;
|
|
37
|
-
constructor(host, port, defaultTimeoutMs, label = "editor bridge", hint = 'Is the editor open with the "Breakpoint MCP" plugin enabled?'
|
|
38
|
+
constructor(host, port, defaultTimeoutMs, label = "editor bridge", hint = 'Is the editor open with the "Breakpoint MCP" plugin enabled?',
|
|
39
|
+
/**
|
|
40
|
+
* Loopback-auth secret provider, read lazily on each connect. When it
|
|
41
|
+
* returns a non-empty string the client sends it as the FIRST line on the
|
|
42
|
+
* connection (see connect()); null/undefined → no auth line, matching an
|
|
43
|
+
* insecure or not-yet-provisioned bridge. Lazy so a secret minted after the
|
|
44
|
+
* host started (editor launched later) is picked up on the next (re)connect.
|
|
45
|
+
*/
|
|
46
|
+
secretProvider) {
|
|
38
47
|
this.host = host;
|
|
39
48
|
this.port = port;
|
|
40
49
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
|
41
50
|
this.label = label;
|
|
42
51
|
this.hint = hint;
|
|
52
|
+
this.secretProvider = secretProvider;
|
|
43
53
|
}
|
|
44
54
|
/** Register a listener for addon-pushed resource-change events. */
|
|
45
55
|
onResourceChanged(cb) {
|
|
@@ -57,6 +67,17 @@ export class BridgeClient {
|
|
|
57
67
|
this.socket = socket;
|
|
58
68
|
this.connecting = null;
|
|
59
69
|
this.clearReconnect();
|
|
70
|
+
// Loopback-auth handshake: if a secret is available it MUST be the first
|
|
71
|
+
// line on the connection. TCP preserves order and the addon drains lines
|
|
72
|
+
// sequentially, so this is guaranteed to be processed before any request
|
|
73
|
+
// line — no await needed. With no secret we send nothing and behave
|
|
74
|
+
// exactly as before (backward-compatible with an insecure bridge). The
|
|
75
|
+
// addon's auth reply carries no id, so onMessage() ignores it; on a
|
|
76
|
+
// failed handshake the addon closes the socket and onClose() reconnects.
|
|
77
|
+
const secret = this.secretProvider?.() ?? null;
|
|
78
|
+
if (secret) {
|
|
79
|
+
socket.write(JSON.stringify({ method: "auth", params: { secret } }) + "\n");
|
|
80
|
+
}
|
|
60
81
|
log(`bridge connected to ${this.host}:${this.port}`);
|
|
61
82
|
resolve(socket);
|
|
62
83
|
});
|