repld-tool 0.3.2__tar.gz → 0.3.4__tar.gz

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.
Files changed (65) hide show
  1. {repld_tool-0.3.2 → repld_tool-0.3.4}/PKG-INFO +1 -1
  2. {repld_tool-0.3.2 → repld_tool-0.3.4}/pyproject.toml +1 -1
  3. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/inject.py +63 -0
  4. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/observe.py +200 -14
  5. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/tab.py +325 -29
  6. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser_dispatch.py +41 -5
  7. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/help.py +63 -16
  8. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/protocol.py +76 -4
  9. {repld_tool-0.3.2 → repld_tool-0.3.4}/LICENSE +0 -0
  10. {repld_tool-0.3.2 → repld_tool-0.3.4}/README.md +0 -0
  11. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/.ruff_cache/.gitignore +0 -0
  12. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/.ruff_cache/0.15.22/14226964882126921391 +0 -0
  13. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/.ruff_cache/CACHEDIR.TAG +0 -0
  14. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/__init__.py +0 -0
  15. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/__main__.py +0 -0
  16. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/bg.py +0 -0
  17. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/bind.py +0 -0
  18. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/bridge.py +0 -0
  19. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/bridge_tools.py +0 -0
  20. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/__init__.py +0 -0
  21. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/browser.py +0 -0
  22. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/capture.py +0 -0
  23. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/cdp.py +0 -0
  24. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/har.py +0 -0
  25. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/injected_source.py +0 -0
  26. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/pin.py +0 -0
  27. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/png.py +0 -0
  28. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/pool.py +0 -0
  29. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/row.py +0 -0
  30. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/selector.py +0 -0
  31. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/session.py +0 -0
  32. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/tab_query.py +0 -0
  33. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/browser/target.py +0 -0
  34. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/channel.py +0 -0
  35. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/cli.py +0 -0
  36. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/cli_args.py +0 -0
  37. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/core_schemas.py +0 -0
  38. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/dashboard.py +0 -0
  39. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/dashboard_cmd.py +0 -0
  40. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/dashboard_html.py +0 -0
  41. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/display.py +0 -0
  42. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/eventlog.py +0 -0
  43. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/events.py +0 -0
  44. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/exec_cmd.py +0 -0
  45. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gate_cmd.py +0 -0
  46. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gates.py +0 -0
  47. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gist_api.py +0 -0
  48. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gist_cmd.py +0 -0
  49. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gist_deps.py +0 -0
  50. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gist_links.py +0 -0
  51. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gist_lint.py +0 -0
  52. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/gists.py +0 -0
  53. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/ipc.py +0 -0
  54. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/kernel.py +0 -0
  55. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/kernel_context.py +0 -0
  56. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/lifecycle_cmd.py +0 -0
  57. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/log_cmd.py +0 -0
  58. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/paths.py +0 -0
  59. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/relaunch.py +0 -0
  60. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/render.py +0 -0
  61. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/runtime.py +0 -0
  62. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/sessions.py +0 -0
  63. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/spawn.py +0 -0
  64. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/state.py +0 -0
  65. {repld_tool-0.3.2 → repld_tool-0.3.4}/src/repld/tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repld-tool
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Persistent Python runtime with MCP channel push. Dev shell and autonomous-agent substrate in one package.
5
5
  Keywords: repl,mcp,agent,browser
6
6
  Author: Fredrik Angelsen
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repld-tool"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  description = "Persistent Python runtime with MCP channel push. Dev shell and autonomous-agent substrate in one package."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -511,6 +511,69 @@ async def list_option_names(tab: "Tab", limit: int = 20) -> list[str]:
511
511
  return result.get("result", {}).get("value") or []
512
512
 
513
513
 
514
+ # Observation DOM watch. The AX diff misses purely presentational reveals
515
+ # (SVG ports mounting on hover render no AX nodes), so pre_observe arms a
516
+ # MutationObserver and post_observe reads it back when the AX diff came up
517
+ # empty. Lives on this world's globalThis: in the utility-world tier the page
518
+ # can't see or tamper with it, and it dies with the document — a navigation
519
+ # between arm and read simply reads back null.
520
+ _DOM_WATCH_START = """
521
+ function() {
522
+ const w = globalThis;
523
+ if (w.__repld_domwatch) { try { w.__repld_domwatch.mo.disconnect(); } catch (e) {} }
524
+ const rec = { added: 0, removed: 0, samples: [] };
525
+ const desc = n => {
526
+ const t = n.getAttribute && n.getAttribute('data-testid');
527
+ if (t) return '[data-testid=' + t + ']';
528
+ if (n.id) return '#' + n.id;
529
+ return '<' + (n.tagName || '?').toLowerCase() + '>';
530
+ };
531
+ const mo = new MutationObserver(muts => {
532
+ for (const m of muts) {
533
+ for (const n of m.addedNodes) if (n.nodeType === 1) {
534
+ rec.added += 1 + (n.querySelectorAll ? n.querySelectorAll('*').length : 0);
535
+ if (rec.samples.length < 8) rec.samples.push(desc(n));
536
+ }
537
+ for (const n of m.removedNodes) if (n.nodeType === 1)
538
+ rec.removed += 1 + (n.querySelectorAll ? n.querySelectorAll('*').length : 0);
539
+ }
540
+ });
541
+ mo.observe(this.document.documentElement, { childList: true, subtree: true });
542
+ w.__repld_domwatch = { mo, rec };
543
+ }
544
+ """
545
+
546
+ _DOM_WATCH_READ = """
547
+ function() {
548
+ const w = globalThis;
549
+ const dw = w.__repld_domwatch;
550
+ if (!dw) return null;
551
+ try { dw.mo.disconnect(); } catch (e) {}
552
+ w.__repld_domwatch = null;
553
+ return dw.rec;
554
+ }
555
+ """
556
+
557
+
558
+ async def start_dom_watch(tab: "Tab") -> bool:
559
+ """Arm the observation DOM watch. False when the engine can't inject."""
560
+ try:
561
+ result = await call_engine(tab, _DOM_WATCH_START, [])
562
+ except Exception:
563
+ return False
564
+ return "exceptionDetails" not in result
565
+
566
+
567
+ async def read_dom_watch(tab: "Tab") -> dict | None:
568
+ """{added, removed, samples} recorded since start_dom_watch, or None."""
569
+ try:
570
+ result = await call_engine(tab, _DOM_WATCH_READ, [])
571
+ except Exception:
572
+ return None
573
+ value = result.get("result", {}).get("value")
574
+ return value if isinstance(value, dict) else None
575
+
576
+
514
577
  async def describe_element(tab: "Tab", el: ResolvedElement) -> tuple[str, str]:
515
578
  """(preview, generated selector) for receipts and error digests."""
516
579
  result = await call_engine(
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import asyncio
12
12
  import time
13
+ from collections import Counter
13
14
  from dataclasses import dataclass, field
14
15
  from urllib.parse import urlparse
15
16
 
@@ -98,6 +99,11 @@ def _node_props(node: dict) -> str:
98
99
  return (" [" + ", ".join(props) + "]") if props else ""
99
100
 
100
101
 
102
+ # A tree signature: (role, name, props) multiset over the same nodes the
103
+ # renderer emits, used by the observation diff.
104
+ TreeSig = Counter
105
+
106
+
101
107
  def _build_lines(
102
108
  nodes_by_id: dict[str, dict],
103
109
  children_map: dict[str, list[str]],
@@ -105,6 +111,7 @@ def _build_lines(
105
111
  depth: int,
106
112
  max_depth: int,
107
113
  lines: list[str],
114
+ sig: TreeSig,
108
115
  ) -> None:
109
116
  if depth > max_depth:
110
117
  return
@@ -115,7 +122,9 @@ def _build_lines(
115
122
  if role in SKIP_ROLES:
116
123
  # Still recurse through skipped roles
117
124
  for child_id in children_map.get(node_id, []):
118
- _build_lines(nodes_by_id, children_map, child_id, depth, max_depth, lines)
125
+ _build_lines(
126
+ nodes_by_id, children_map, child_id, depth, max_depth, lines, sig
127
+ )
119
128
  return
120
129
 
121
130
  name = _node_name(node)
@@ -126,24 +135,31 @@ def _build_lines(
126
135
  label += f" {name!r}"
127
136
  label += props
128
137
  lines.append(label)
138
+ sig[(role, name, props)] += 1
129
139
 
130
140
  if role in LEAF_ROLES:
131
141
  return
132
142
 
133
143
  for child_id in children_map.get(node_id, []):
134
- _build_lines(nodes_by_id, children_map, child_id, depth + 1, max_depth, lines)
144
+ _build_lines(
145
+ nodes_by_id, children_map, child_id, depth + 1, max_depth, lines, sig
146
+ )
135
147
 
136
148
 
137
- async def build_tree(tab: "Tab", max_depth: int = 6) -> list[str]:
149
+ async def build_tree_sig(tab: "Tab", max_depth: int = 6) -> tuple[list[str], TreeSig]:
138
150
  """Compact accessibility tree from CDP Accessibility.getFullAXTree.
139
151
 
140
- Returns list of indented text lines.
152
+ Returns (indented text lines, signature multiset). The signature counts
153
+ exactly the nodes the lines render — same roles, same depth cap — so a
154
+ diff of two signatures describes what a reader of the two trees would see
155
+ change.
141
156
  """
142
157
  result = await tab._exec("Accessibility.getFullAXTree", {})
143
158
 
159
+ sig: TreeSig = Counter()
144
160
  nodes = result.get("nodes", [])
145
161
  if not nodes:
146
- return ["(empty tree)"]
162
+ return ["(empty tree)"], sig
147
163
 
148
164
  nodes_by_id: dict[str, dict] = {n["nodeId"]: n for n in nodes}
149
165
  children_map: dict[str, list[str]] = {}
@@ -161,9 +177,14 @@ async def build_tree(tab: "Tab", max_depth: int = 6) -> list[str]:
161
177
 
162
178
  lines: list[str] = []
163
179
  for root_id in root_ids:
164
- _build_lines(nodes_by_id, children_map, root_id, 0, max_depth, lines)
180
+ _build_lines(nodes_by_id, children_map, root_id, 0, max_depth, lines, sig)
165
181
 
166
- return lines or ["(empty tree)"]
182
+ return lines or ["(empty tree)"], sig
183
+
184
+
185
+ async def build_tree(tab: "Tab", max_depth: int = 6) -> list[str]:
186
+ lines, _ = await build_tree_sig(tab, max_depth)
187
+ return lines
167
188
 
168
189
 
169
190
  async def build_aria_tree(tab: "Tab") -> list[str]:
@@ -339,25 +360,29 @@ async def compose_tree(
339
360
  tab: "Tab",
340
361
  session: "BrowserSession",
341
362
  max_depth: int = 8,
342
- ) -> tuple[list[str], list["Tab"]]:
363
+ ) -> tuple[list[str], list["Tab"], dict[str, TreeSig]]:
343
364
  """Build accessibility tree with iframe children inlined.
344
365
 
345
- Returns (lines, iframe_child_tabs).
366
+ Returns (lines, iframe_child_tabs, signatures keyed by target_id).
346
367
  """
347
368
 
348
369
  # Get base tree
349
- lines = await build_tree(tab, max_depth=max_depth)
370
+ lines, sig = await build_tree_sig(tab, max_depth=max_depth)
371
+ sigs: dict[str, TreeSig] = {tab.target_id: sig}
350
372
  iframe_children = await _discover_iframe_children(tab, session)
351
373
 
352
374
  if not iframe_children:
353
- return lines, []
375
+ return lines, [], sigs
354
376
 
355
377
  child_trees: dict[str, list[str]] = {}
356
378
  for child in iframe_children:
357
- child_trees[child.target_id] = await build_tree(child, max_depth=max_depth - 2)
379
+ child_lines, child_sig = await build_tree_sig(child, max_depth=max_depth - 2)
380
+ child_trees[child.target_id] = child_lines
381
+ sigs[child.target_id] = child_sig
358
382
  return (
359
383
  _inline_iframe_lines(lines, iframe_children, child_trees),
360
384
  iframe_children,
385
+ sigs,
361
386
  )
362
387
 
363
388
 
@@ -428,6 +453,9 @@ class Observation:
428
453
  tree: list[str]
429
454
  network: list[NetworkEntry]
430
455
  console: list[str]
456
+ # None → no diff available (browser_open, or the page navigated);
457
+ # [] → diff computed, nothing changed.
458
+ changes: list[str] | None = None
431
459
 
432
460
 
433
461
  # ---------------------------------------------------------------------------
@@ -445,6 +473,14 @@ class PreObservation:
445
473
  # These were two separate fields until the cutoff moved to `events`; they
446
474
  # only ever held the same values, in both construction sites.
447
475
  snapshots: dict[str, int] = field(default_factory=dict)
476
+ # target_id → AX-tree signature for the observation diff. None (the
477
+ # browser_open path constructs PreObservation directly) suppresses the
478
+ # diff rather than reporting a fresh document as all-appeared.
479
+ tree_sigs: dict[str, TreeSig] | None = None
480
+ url: str = ""
481
+ # True when the engine's MutationObserver was armed — post_observe reads
482
+ # it back to catch presentational reveals the AX diff can't see.
483
+ dom_watch: bool = False
448
484
 
449
485
 
450
486
  def _snapshot_max_ids(tabs: list["Tab"]) -> dict[str, int]:
@@ -475,12 +511,30 @@ def _snapshot_max_ids(tabs: list["Tab"]) -> dict[str, int]:
475
511
 
476
512
 
477
513
  async def pre_observe(tab: "Tab", session: "BrowserSession") -> PreObservation:
478
- """Capture state before a mutation. Fast — no blocking."""
514
+ """Capture state before a mutation.
515
+
516
+ The signature capture fetches the same AX trees post_observe will — same
517
+ depths (compose_tree's 8, children at 6) — so the diff compares like with
518
+ like. Best-effort: a page the AX domain chokes on still gets its mutation
519
+ observed, just without the changes section.
520
+ """
521
+ from . import inject
522
+
479
523
  iframe_children = await _discover_iframe_children(tab, session)
480
524
  all_tabs = [tab] + iframe_children
525
+ tree_sigs: dict[str, TreeSig] | None = {}
526
+ try:
527
+ _, tree_sigs[tab.target_id] = await build_tree_sig(tab, max_depth=8)
528
+ for child in iframe_children:
529
+ _, tree_sigs[child.target_id] = await build_tree_sig(child, max_depth=6)
530
+ except Exception:
531
+ tree_sigs = None
481
532
  return PreObservation(
482
533
  iframe_children=iframe_children,
483
534
  snapshots=_snapshot_max_ids(all_tabs),
535
+ tree_sigs=tree_sigs,
536
+ url=tab.url,
537
+ dom_watch=await inject.start_dom_watch(tab),
484
538
  )
485
539
 
486
540
 
@@ -563,6 +617,112 @@ def console_delta(tabs: list["Tab"], pre_ids: dict[str, int]) -> list[str]:
563
617
  return lines
564
618
 
565
619
 
620
+ # ---------------------------------------------------------------------------
621
+ # Tree diff
622
+ # ---------------------------------------------------------------------------
623
+
624
+ _DIFF_MAX_LINES = 12
625
+
626
+
627
+ def _diff_one(pre: TreeSig, post: TreeSig) -> tuple[Counter, Counter, list[tuple]]:
628
+ """(appeared, gone, changed) between two signatures.
629
+
630
+ An entry removed and added under the same (role, name) with different
631
+ props is a state change, not a departure plus an arrival — that pairing
632
+ is what turns "− button 'Save' [disabled] / + button 'Save'" into
633
+ "~ button 'Save' [disabled] → enabled".
634
+ """
635
+ added = post - pre
636
+ removed = pre - post
637
+ changed: list[tuple] = []
638
+ removed_by_rn: dict[tuple[str, str], list[tuple[str, int]]] = {}
639
+ for (role, name, props), cnt in removed.items():
640
+ removed_by_rn.setdefault((role, name), []).append((props, cnt))
641
+ for key in list(added):
642
+ role, name, new_props = key
643
+ buckets = removed_by_rn.get((role, name))
644
+ if not buckets:
645
+ continue
646
+ old_props, old_cnt = buckets[0]
647
+ n = min(added[key], old_cnt)
648
+ changed.append((role, name, old_props, new_props, n))
649
+ added[key] -= n
650
+ removed[(role, name, old_props)] -= n
651
+ if old_cnt - n:
652
+ buckets[0] = (old_props, old_cnt - n)
653
+ else:
654
+ buckets.pop(0)
655
+ if not buckets:
656
+ del removed_by_rn[(role, name)]
657
+ return +added, +removed, changed
658
+
659
+
660
+ def _entry_str(role: str, name: str, props: str) -> str:
661
+ label = role
662
+ if name:
663
+ label += f" {name!r}"
664
+ return label + props
665
+
666
+
667
+ def tree_diff_lines(
668
+ pre_sigs: dict[str, TreeSig],
669
+ post_sigs: dict[str, TreeSig],
670
+ main_target: str,
671
+ ) -> list[str]:
672
+ """Render the appeared/gone/changed summary, main tab first.
673
+
674
+ An iframe present on only one side reads as its whole tree
675
+ appearing/disappearing, which is accurate: the frame itself came or went.
676
+ """
677
+ detail: list[str] = []
678
+ totals = [0, 0, 0]
679
+ targets = [main_target] + [t for t in post_sigs if t != main_target]
680
+ targets += [t for t in pre_sigs if t not in post_sigs]
681
+ for target in targets:
682
+ added, removed, changed = _diff_one(
683
+ pre_sigs.get(target) or Counter(), post_sigs.get(target) or Counter()
684
+ )
685
+ totals[0] += sum(added.values())
686
+ totals[1] += sum(removed.values())
687
+ totals[2] += sum(n for *_, n in changed)
688
+ prefix = "" if target == main_target else f"{target} "
689
+ for (role, name, props), cnt in sorted(added.items(), key=lambda kv: -kv[1]):
690
+ mult = f" ×{cnt}" if cnt > 1 else ""
691
+ detail.append(f"+ {prefix}{_entry_str(role, name, props)}{mult}")
692
+ for role, name, old, new, n in changed:
693
+ mult = f" ×{n}" if n > 1 else ""
694
+ old_s = old.strip() or "[none]"
695
+ new_s = new.strip() or "[none]"
696
+ detail.append(
697
+ f"~ {prefix}{_entry_str(role, name, '')} {old_s} → {new_s}{mult}"
698
+ )
699
+ for (role, name, props), cnt in sorted(removed.items(), key=lambda kv: -kv[1]):
700
+ mult = f" ×{cnt}" if cnt > 1 else ""
701
+ detail.append(f"- {prefix}{_entry_str(role, name, props)}{mult}")
702
+
703
+ if not detail:
704
+ return []
705
+ header = f"changes: {totals[0]} appeared, {totals[1]} gone, {totals[2]} changed"
706
+ if len(detail) > _DIFF_MAX_LINES:
707
+ hidden = len(detail) - _DIFF_MAX_LINES
708
+ detail = detail[:_DIFF_MAX_LINES] + [f"… +{hidden} more (full tree below)"]
709
+ return [header] + [" " + d for d in detail]
710
+
711
+
712
+ def _dom_delta_line(rec: dict) -> str | None:
713
+ """The AX-silent fallback line — presentational nodes came or went."""
714
+ added = rec.get("added") or 0
715
+ removed = rec.get("removed") or 0
716
+ if not added and not removed:
717
+ return None
718
+ counts = Counter(rec.get("samples") or [])
719
+ sample = ", ".join(f"{s} ×{n}" if n > 1 else s for s, n in counts.most_common(4))
720
+ line = f"changes: none in the AX tree — dom: +{added} −{removed} elements"
721
+ if sample:
722
+ line += f" ({sample})"
723
+ return line
724
+
725
+
566
726
  # ---------------------------------------------------------------------------
567
727
  # Formatting
568
728
  # ---------------------------------------------------------------------------
@@ -576,6 +736,14 @@ def format_observation(obs: Observation) -> str:
576
736
  parts.append(f"url: {obs.url} (settled in {obs.settle_ms}ms)")
577
737
  parts.append("")
578
738
 
739
+ # Changes — what this mutation did to the AX tree, before the full tree.
740
+ if obs.changes is not None:
741
+ if obs.changes:
742
+ parts.extend(obs.changes)
743
+ else:
744
+ parts.append("changes: none (AX tree unchanged)")
745
+ parts.append("")
746
+
579
747
  # Tree
580
748
  tree_count = len(obs.tree)
581
749
  parts.append(f"tree ({tree_count} nodes):")
@@ -640,7 +808,24 @@ async def post_observe(
640
808
 
641
809
  # Build composed tree — re-discovers iframes, which may differ from
642
810
  # pre.iframe_children if the mutation added/removed one.
643
- tree_lines, fresh_iframes = await compose_tree(tab, session)
811
+ tree_lines, fresh_iframes, post_sigs = await compose_tree(tab, session)
812
+
813
+ # Diff needs a pre signature *and* the same document — after a navigation
814
+ # the whole tree is new, and reporting it as a few hundred appearances
815
+ # would bury the one section that exists to be short.
816
+ from . import inject
817
+
818
+ dom_rec = await inject.read_dom_watch(tab) if pre.dom_watch else None
819
+ changes: list[str] | None = None
820
+ if pre.tree_sigs is not None:
821
+ if tab.url == pre.url:
822
+ changes = tree_diff_lines(pre.tree_sigs, post_sigs, tab.target_id)
823
+ if not changes and dom_rec:
824
+ line = _dom_delta_line(dom_rec)
825
+ if line:
826
+ changes = [line]
827
+ else:
828
+ changes = ["changes: (page navigated — tree replaced)"]
644
829
 
645
830
  # A mutation can spawn a brand-new iframe (e.g. an OAuth popup frame)
646
831
  # that wasn't in pre.iframe_children and so never got a quiet-period
@@ -675,6 +860,7 @@ async def post_observe(
675
860
  tree=tree_lines,
676
861
  network=net_entries,
677
862
  console=console_lines,
863
+ changes=changes,
678
864
  )
679
865
 
680
866
  text = format_observation(obs)