expluro 0.2.0__tar.gz → 0.3.0__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 (30) hide show
  1. {expluro-0.2.0 → expluro-0.3.0}/PKG-INFO +1 -1
  2. {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/PKG-INFO +1 -1
  3. {expluro-0.2.0 → expluro-0.3.0}/pyproject.toml +1 -1
  4. {expluro-0.2.0 → expluro-0.3.0}/topo/capture.py +40 -5
  5. {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/_branch_detection.py +12 -3
  6. {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/_permissions.py +25 -4
  7. {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/acp.py +125 -44
  8. {expluro-0.2.0 → expluro-0.3.0}/topo/picker.py +24 -5
  9. {expluro-0.2.0 → expluro-0.3.0}/topo/renderer.py +32 -2
  10. {expluro-0.2.0 → expluro-0.3.0}/topo/session.py +109 -1
  11. expluro-0.3.0/topo/snapshot.py +482 -0
  12. expluro-0.2.0/topo/snapshot.py +0 -3
  13. {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/SOURCES.txt +0 -0
  14. {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/dependency_links.txt +0 -0
  15. {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/entry_points.txt +0 -0
  16. {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/requires.txt +0 -0
  17. {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/top_level.txt +0 -0
  18. {expluro-0.2.0 → expluro-0.3.0}/setup.cfg +0 -0
  19. {expluro-0.2.0 → expluro-0.3.0}/topo/__init__.py +0 -0
  20. {expluro-0.2.0 → expluro-0.3.0}/topo/auth.py +0 -0
  21. {expluro-0.2.0 → expluro-0.3.0}/topo/backend.py +0 -0
  22. {expluro-0.2.0 → expluro-0.3.0}/topo/constants.py +0 -0
  23. {expluro-0.2.0 → expluro-0.3.0}/topo/display.py +0 -0
  24. {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/__init__.py +0 -0
  25. {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/base.py +0 -0
  26. {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/claude.py +0 -0
  27. {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/kiro_session_files.py +0 -0
  28. {expluro-0.2.0 → expluro-0.3.0}/topo/main.py +0 -0
  29. {expluro-0.2.0 → expluro-0.3.0}/topo/session_files.py +0 -0
  30. {expluro-0.2.0 → expluro-0.3.0}/topo/update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expluro
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Expluro CLI — graph layer on top of your AI agent
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: claude-agent-sdk>=0.0.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: expluro
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Expluro CLI — graph layer on top of your AI agent
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: claude-agent-sdk>=0.0.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "expluro"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Expluro CLI — graph layer on top of your AI agent"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -27,6 +27,7 @@ from .constants import APP_DIR
27
27
  if TYPE_CHECKING:
28
28
  from .backend import BackendClient
29
29
  from .drivers.base import BaseDriver, DriverEvent, ToolHookPayload
30
+ from .snapshot import SnapshotManager
30
31
 
31
32
  logger = logging.getLogger(__name__)
32
33
 
@@ -56,10 +57,16 @@ class EventCapture:
56
57
  backend: "BackendClient",
57
58
  graph_id: str,
58
59
  node_id: str,
60
+ snapshot_manager: "SnapshotManager | None" = None,
59
61
  ) -> None:
60
62
  self._backend = backend
61
63
  self._graph_id = graph_id
62
64
  self._node_id = node_id
65
+ # Optional — when present, every side-effecting tool call gets a
66
+ # filesystem snapshot committed to a shadow git repo and the resulting
67
+ # commit hash + diff summary attached to the post_tool_use event.
68
+ # Powers `/branch` and `/checkout` filesystem rewind.
69
+ self._snapshot_manager: "SnapshotManager | None" = snapshot_manager
63
70
  self._last_event_id: str | None = None
64
71
  # Accumulated streaming text for the current turn.
65
72
  # Flushed as message_ai on turn_result if text_complete never fires
@@ -297,16 +304,44 @@ class EventCapture:
297
304
 
298
305
  async def _on_post_tool(self, payload: "ToolHookPayload") -> None:
299
306
  is_side_effecting = payload.tool_name in SIDE_EFFECTING_TOOLS
307
+
308
+ # Snapshot the filesystem after a side-effecting tool. Even tools we
309
+ # think are read-only sometimes touch state (e.g. Bash that runs a
310
+ # build, Grep with --rewrite), so we let the SnapshotManager dedupe:
311
+ # if nothing changed, the commit is a no-op and returns the existing
312
+ # HEAD hash. Diff summary is attached to the event for browser display.
313
+ snapshot_hash: str | None = None
314
+ diff_summary: dict | None = None
315
+ if is_side_effecting and self._snapshot_manager is not None:
316
+ try:
317
+ snapshot_hash, diff_summary = await self._snapshot_manager.commit(
318
+ label=f"{payload.tool_name} {payload.tool_use_id[:8]}",
319
+ )
320
+ except Exception:
321
+ logger.exception("snapshot commit failed for %s", payload.tool_name)
322
+
323
+ event_data: dict[str, Any] = {
324
+ "tool_output": _truncate(payload.tool_output),
325
+ "is_error": payload.is_error,
326
+ "is_side_effecting": is_side_effecting,
327
+ }
328
+ if snapshot_hash:
329
+ event_data["snapshot_hash"] = snapshot_hash
330
+ if diff_summary:
331
+ event_data["diff_summary"] = diff_summary
332
+
300
333
  event = self._build_event(
301
334
  event_type="post_tool_use",
302
335
  tool_name=payload.tool_name,
303
336
  tool_use_id=payload.tool_use_id,
304
- data={
305
- "tool_output": _truncate(payload.tool_output),
306
- "is_error": payload.is_error,
307
- "is_side_effecting": is_side_effecting,
308
- },
337
+ data=event_data,
309
338
  )
339
+ # The Event model has a snapshot_hash field — the backend pulls it
340
+ # from the top level of the request payload, not from `data`. Set it
341
+ # here so it lands in the indexed column, not just the JSON blob.
342
+ if snapshot_hash:
343
+ event["snapshot_hash"] = snapshot_hash
344
+
310
345
  if self._pending_user_message_text is not None:
311
346
  self._buffered_hook_events.append(event)
312
347
  return
@@ -83,8 +83,17 @@ def parse_branch_flag(buffered_head: str) -> tuple[float | None, str]:
83
83
  Returns (confidence, remaining_text). When the head doesn't start with
84
84
  a valid tag, confidence is None and the entire buffered_head is
85
85
  returned as remaining_text — caller should treat it as regular content.
86
+
87
+ If the tag matches structurally but the digits are malformed (e.g.
88
+ `<!--BRANCH:.-->`), we still consume the tag bytes from the remaining
89
+ text but return None for confidence. That way a weird model emission
90
+ can't leak the tag to the user OR crash the dispatcher.
86
91
  """
87
92
  m = BRANCH_FLAG_START_RE.match(buffered_head)
88
- if m:
89
- return float(m.group(1)), buffered_head[m.end():]
90
- return None, buffered_head
93
+ if not m:
94
+ return None, buffered_head
95
+ try:
96
+ confidence = float(m.group(1))
97
+ except ValueError:
98
+ return None, buffered_head[m.end():]
99
+ return confidence, buffered_head[m.end():]
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
  import logging
19
19
  from dataclasses import dataclass, field
20
20
  from enum import Enum
21
+ from html import escape as _html_escape
21
22
  from typing import Any
22
23
 
23
24
  logger = logging.getLogger(__name__)
@@ -72,24 +73,44 @@ async def ask_user_for_permission(
72
73
  """
73
74
  # Late imports to avoid cycles with picker / renderer at module-load time.
74
75
  from ..picker import pick_option
75
- from ..renderer import pause_active_spinner, resume_active_spinner
76
+ from ..renderer import (
77
+ pause_active_md_stream,
78
+ pause_active_spinner,
79
+ resume_active_spinner,
80
+ )
81
+
82
+ # prompt_toolkit's HTML(...) is strict XML; any literal `&`, `<`, `>`
83
+ # or quote in agent-provided text (Kiro tool titles like
84
+ # "Run command: cat /etc/hosts 2>&1" — note the bare `&`) crashes the
85
+ # picker with an ExpatError, the wrapper deny-fails closed, and the
86
+ # user sees no prompt at all. Escape user/agent-provided substrings
87
+ # before embedding them inside our HTML-formatted picker text.
88
+ safe_tool_name = _html_escape(tool_name)
89
+ safe_summary = _html_escape(summary) if summary else ""
76
90
 
77
91
  summary_line = (
78
- f"\n <ansibrightblack> {summary}</ansibrightblack>" if summary else ""
92
+ f"\n <ansibrightblack> {safe_summary}</ansibrightblack>" if safe_summary else ""
79
93
  )
80
94
  header = (
81
- f" <ansicyan>⚙ {tool_name}</ansicyan> "
95
+ f" <ansicyan>⚙ {safe_tool_name}</ansicyan> "
82
96
  f"<ansibrightblack>wants to run:</ansibrightblack>{summary_line}"
83
97
  )
84
98
 
85
99
  options: list[tuple[str, str]] = [
86
100
  ("Allow", "allow"),
87
- (f"Always allow {tool_name}", "always"),
101
+ (f"Always allow {safe_tool_name}", "always"),
88
102
  ("Deny", "deny"),
89
103
  ("Bypass all checks", "bypass"),
90
104
  ]
91
105
 
106
+ # Hand the terminal off to prompt_toolkit cleanly: stop the spinner and
107
+ # flush+stop any active rich.Live markdown stream. If we skip the
108
+ # md-stream flush, an in-progress streaming response will keep
109
+ # repainting the cursor area on every refresh tick and the picker is
110
+ # invisible — the user thinks the agent went silent, but the agent is
111
+ # actually waiting on an approval that never gets a chance to render.
92
112
  pause_active_spinner()
113
+ pause_active_md_stream()
93
114
  try:
94
115
  result = await pick_option(header, options)
95
116
  except KeyboardInterrupt:
@@ -45,6 +45,7 @@ from acp.schema import AllowedOutcome, DeniedOutcome
45
45
 
46
46
  from ._branch_detection import (
47
47
  BRANCH_DETECTION_INSTRUCTION,
48
+ BRANCH_FLAG_START_RE,
48
49
  HEAD_BUFFER_MAX,
49
50
  parse_branch_flag,
50
51
  )
@@ -103,15 +104,19 @@ class ACPDriver(BaseDriver):
103
104
  # Permission tracking. always_allowed survives across branches;
104
105
  # bypass_all is reset on branch switch (see reset_context).
105
106
  self._permission_state: PermissionState = PermissionState()
106
- # Auto-branch head buffering. Per-turn state: accumulate the start of
107
- # the streamed text response until we either see a newline or hit
108
- # HEAD_BUFFER_MAX, then parse for the BRANCH:X.XX tag and emit a
109
- # branch_signal event. Reset at the top of each _run_prompt.
110
- # If a tool call arrives before any text, we treat the head as
111
- # already resolved (no tag, no branch fire) so subsequent text
112
- # streams through normally.
107
+ # Auto-branch head buffering. Per-segment state: accumulate the
108
+ # start of each streamed text segment until we either see a newline
109
+ # or hit HEAD_BUFFER_MAX, then parse for the BRANCH:X.XX tag.
110
+ # A "segment" begins at turn start AND after every tool call, because
111
+ # Kiro re-emits the tag at the head of post-tool text. Resetting the
112
+ # head buffer per segment is what catches chunked tags
113
+ # (`<!--BRANCH:0.10` + `-->...`) that a single regex sub on one chunk
114
+ # would miss.
115
+ # branch_signal only fires once per turn even if Kiro repeats the
116
+ # tag — we use the very first parse as authoritative.
113
117
  self._head_buf: str = ""
114
118
  self._head_parsed: bool = False
119
+ self._branch_signal_fired_this_turn: bool = False
115
120
 
116
121
  # ------------------------------------------------------------------
117
122
  # Lifecycle
@@ -371,10 +376,10 @@ class ACPDriver(BaseDriver):
371
376
  except asyncio.QueueEmpty:
372
377
  break
373
378
 
374
- # Reset auto-branch head state at the start of every turn — the
375
- # response we're about to receive should begin with the BRANCH tag.
379
+ # Reset auto-branch head state at the start of every turn.
376
380
  self._head_buf = ""
377
381
  self._head_parsed = False
382
+ self._branch_signal_fired_this_turn = False
378
383
 
379
384
  # Fire-and-forget the prompt as a background task. The connection's
380
385
  # `prompt()` returns a PromptResponse when the agent finishes. We
@@ -400,6 +405,12 @@ class ACPDriver(BaseDriver):
400
405
  session_id=self._session_id,
401
406
  prompt=[text_block(framed_prompt)],
402
407
  )
408
+ # Flush any text still pending in the head buffer. Short post-
409
+ # tool responses (e.g. "Your username is yab", 21 chars, no
410
+ # newline) never trip the size/newline thresholds during
411
+ # streaming and would otherwise be lost when the turn ends.
412
+ assert self._session_id is not None
413
+ await self._flush_head_buffer(self._session_id)
403
414
  # Emit a result event capturing usage if present, then end-of-turn.
404
415
  data: dict[str, Any] = {}
405
416
  if response is not None:
@@ -435,12 +446,11 @@ class ACPDriver(BaseDriver):
435
446
  """
436
447
  Text-streaming entry point with auto-branch head buffering.
437
448
 
438
- Buffers the very first chunk(s) of each turn until we see a newline
439
- or hit HEAD_BUFFER_MAX, then parses for the BRANCH tag. If found,
440
- emits a `branch_signal` event before any visible text — session.py
441
- can then decide whether to fork at this exact moment, before the
442
- renderer prints anything. After the head is resolved (with or
443
- without a tag), text streams through normally.
449
+ Buffers the start of each text segment (turn-start, or post-tool
450
+ re-entry) until we see a newline or hit HEAD_BUFFER_MAX, then
451
+ delegates to _flush_head_buffer for tag parsing + emission. That
452
+ helper centralises the multi-tag, partial-tag, and malformed-tag
453
+ defenses so this handler stays a thin entry point.
444
454
  """
445
455
  if self._head_parsed:
446
456
  await self._emit(
@@ -454,45 +464,89 @@ class ACPDriver(BaseDriver):
454
464
  return
455
465
 
456
466
  self._head_parsed = True
457
- confidence, remaining = parse_branch_flag(self._head_buf)
467
+ await self._flush_head_buffer(session_id)
468
+
469
+ async def _flush_head_buffer(self, session_id: str) -> None:
470
+ """
471
+ Drain the current head buffer (if any), emitting at most one
472
+ branch_signal event (only on the first parsed tag of the turn) and
473
+ a text event for whatever non-tag content remains.
474
+
475
+ Called from two sites:
476
+ 1. _resolve_head_for_non_text — when a tool call arrives.
477
+ 2. End of _run_prompt — when the turn finishes, to ensure any
478
+ short post-tool text response (e.g. "Your username is yab"
479
+ with no newline) doesn't get stuck in the buffer and lost.
480
+
481
+ Defensive against three model misbehaviours:
482
+ - Multiple stacked tags (`<!--BRANCH:0.0--><!--BRANCH:0.5-->Hi`):
483
+ strip ALL leading tags, not just the first, so none leak.
484
+ - Partial tag at flush time (`<!--BRANCH:0.5` with no `-->`):
485
+ happens if a tool call interrupts mid-tag-stream. Drop the
486
+ partial-tag bytes rather than emitting them as visible text.
487
+ - Malformed digits (`<!--BRANCH:.-->`): handled inside
488
+ parse_branch_flag — the tag is consumed, no confidence
489
+ recorded.
490
+
491
+ Caller is responsible for resetting `_head_parsed` after this if
492
+ appropriate. We deliberately do not reset state here so the
493
+ function is reusable.
494
+ """
495
+ if not self._head_buf:
496
+ return
497
+ buf = self._head_buf
458
498
  self._head_buf = ""
459
- if confidence is not None:
460
- logger.debug("ACPDriver: branch tag parsed (confidence=%.2f)", confidence)
499
+
500
+ first_confidence: float | None = None
501
+ # Strip every leading tag. Defensive against double-emission.
502
+ while True:
503
+ confidence, rest = parse_branch_flag(buf)
504
+ if confidence is None and rest == buf:
505
+ # No tag matched at all — buf is plain content.
506
+ break
507
+ # A tag was consumed (confidence may still be None if the
508
+ # tag's digits were malformed). Capture the first valid
509
+ # confidence we see.
510
+ if first_confidence is None and confidence is not None:
511
+ first_confidence = confidence
512
+ buf = rest
513
+ if not buf:
514
+ break
515
+
516
+ # If the buffer is now a partial tag prefix (no closing `-->`),
517
+ # the model started a tag we never saw the end of. Drop it
518
+ # rather than leak `<!--BRANCH:0.5` to the renderer.
519
+ if buf.startswith("<!--BRANCH:") and "-->" not in buf:
520
+ buf = ""
521
+
522
+ if first_confidence is not None and not self._branch_signal_fired_this_turn:
523
+ self._branch_signal_fired_this_turn = True
461
524
  await self._emit(
462
525
  DriverEvent(
463
526
  type="branch_signal",
464
- data={"confidence": confidence, "session_id": session_id},
465
- raw=raw,
527
+ data={"confidence": first_confidence, "session_id": session_id},
528
+ raw=None,
466
529
  )
467
530
  )
468
- if remaining:
469
- await self._emit(
470
- DriverEvent(type="text", data={"text": remaining, "session_id": session_id}, raw=raw)
471
- )
472
- else:
473
- # No tag — flush whatever buffered as regular text so nothing is
474
- # silently dropped even if the model ignored the instruction.
475
- if remaining:
476
- await self._emit(
477
- DriverEvent(type="text", data={"text": remaining, "session_id": session_id}, raw=raw)
478
- )
531
+ if buf:
532
+ await self._emit(
533
+ DriverEvent(type="text", data={"text": buf, "session_id": session_id}, raw=None)
534
+ )
479
535
 
480
536
  async def _resolve_head_for_non_text(self, session_id: str) -> None:
481
537
  """
482
- Mark the head as resolved when a non-text event (tool call) arrives
483
- before any AgentMessageChunk. Flushes any buffered partial text as
484
- regular text so it isn't silently lost. No branch_signal can fire
485
- at this point because we never saw a tag.
538
+ Called when a non-text event (tool call) arrives. Flushes any
539
+ buffered head text and resets so the next text segment re-enters
540
+ tag parsing.
541
+
542
+ That re-entry is what catches Kiro's habit of repeating the
543
+ BRANCH tag at the head of each post-tool text segment, including
544
+ when the tag is split across multiple stream chunks (a single
545
+ regex sub on one chunk would miss the second half).
486
546
  """
487
- if self._head_parsed:
488
- return
489
- self._head_parsed = True
490
- if self._head_buf:
491
- buffered = self._head_buf
492
- self._head_buf = ""
493
- await self._emit(
494
- DriverEvent(type="text", data={"text": buffered, "session_id": session_id}, raw=None)
495
- )
547
+ await self._flush_head_buffer(session_id)
548
+ # Reset for the next text segment so it re-enters head parsing.
549
+ self._head_parsed = False
496
550
 
497
551
  async def _fire_pre_tool_hook(self, payload: ToolHookPayload) -> None:
498
552
  for cb in self._pre_tool_hooks:
@@ -666,6 +720,33 @@ class _ACPClient(Client):
666
720
  await self._driver._emit(
667
721
  DriverEvent(type="tool_use", data=event_data, raw=update)
668
722
  )
723
+ # Fast tools (Kiro's `read` for small files, for example) emit a
724
+ # single ToolCallStart with status already "completed" or
725
+ # "failed" and never send a follow-up ToolCallProgress. If we
726
+ # only emit tool_use the browser shows the call as "running…"
727
+ # forever. Emit tool_result inline when the start update is
728
+ # already terminal.
729
+ start_status = getattr(update, "status", None)
730
+ if start_status is not None and str(start_status).lower() in ("completed", "failed"):
731
+ tool_output = _extract_tool_output(update)
732
+ is_error = str(start_status).lower() == "failed"
733
+ payload_out = self._driver._active_tool_calls.pop(tool_use_id, None)
734
+ if payload_out is not None:
735
+ payload_out.tool_output = tool_output
736
+ payload_out.is_error = is_error
737
+ await self._driver._fire_post_tool_hook(payload_out)
738
+ await self._driver._emit(
739
+ DriverEvent(
740
+ type="tool_result",
741
+ data={
742
+ "tool_use_id": tool_use_id,
743
+ "tool_output": tool_output,
744
+ "is_error": is_error,
745
+ "session_id": session_id,
746
+ },
747
+ raw=update,
748
+ )
749
+ )
669
750
  return
670
751
 
671
752
  if isinstance(update, ToolCallProgress):
@@ -1,9 +1,16 @@
1
1
  """
2
2
  picker.py — Arrow-key pickers for session selection and permission prompts.
3
+
4
+ prompt_toolkit's HTML(...) is strict XML, so any literal `&`, `<`, `>`, or
5
+ quote in user/agent-provided text (graph names, node labels, message bodies,
6
+ tool titles) crashes the picker with an ExpatError. We escape every such
7
+ substring before embedding it into the HTML format strings below. Static
8
+ markup (tags like <ansicyan>...</ansicyan>) is hand-written and stays raw.
3
9
  """
4
10
  from __future__ import annotations
5
11
 
6
12
  import datetime
13
+ from html import escape as _html_escape
7
14
  from typing import Any
8
15
 
9
16
  from prompt_toolkit import Application
@@ -87,13 +94,16 @@ async def pick_node(nodes: list[dict[str, Any]], has_more: bool, current_node_id
87
94
  is_current = choice.get("id") == current_node_id
88
95
  label = choice.get("label") or "(untitled)"
89
96
  truncated = label[:52] + ("…" if len(label) > 52 else "")
97
+ # Node labels can contain `&`, `<`, `>` (assistant-generated
98
+ # summaries often do). Escape before embedding in HTML.
99
+ safe_truncated = _html_escape(truncated)
90
100
  node_id_short = str(choice.get("id", ""))[:8]
91
101
  status = choice.get("status", "active")
92
102
  sc = STATUS_COLOR.get(status, "ansibrightblack")
93
103
  msg_count = len(choice.get("materialized_context", []))
94
104
  time_str = _relative_time(choice.get("updated_at", ""))
95
105
  current_tag = " <ansibrightblack>(current)</ansibrightblack>" if is_current else ""
96
- label_fmt = f"<ansiwhite><b>{truncated}</b></ansiwhite>" if selected else f"<ansiwhite>{truncated}</ansiwhite>"
106
+ label_fmt = f"<ansiwhite><b>{safe_truncated}</b></ansiwhite>" if selected else f"<ansiwhite>{safe_truncated}</ansiwhite>"
97
107
  lines.append(f" {cursor}{label_fmt}{current_tag}\n")
98
108
  time_part = f" · {time_str}" if time_str else ""
99
109
  lines.append(f" <ansibrightblack>{node_id_short} · <{sc}>{status}</{sc}> · {msg_count} msg{'s' if msg_count != 1 else ''}{time_part}</ansibrightblack>\n\n")
@@ -155,15 +165,19 @@ async def pick_message(context: list[dict[str, Any]]) -> int | None:
155
165
  for i, msg in enumerate(reversed_ctx):
156
166
  role = msg.get("role", "user")
157
167
  content = (msg.get("content") or "").replace("\n", " ").strip()
168
+ # Message bodies routinely contain `<`, `>`, `&` (code snippets,
169
+ # diffs, shell commands). Escape before HTML interpolation.
170
+ preview = content[:64] + ("…" if len(content) > 64 else "")
171
+ safe_preview = _html_escape(preview)
158
172
  is_active = i == active_display_idx
159
173
  if role == "assistant":
160
174
  cursor = "<ansiwhite>▶ </ansiwhite>" if is_active else " "
161
175
  role_fmt = "<ansigreen>[assistant]</ansigreen>"
162
- text_fmt = f"<ansiwhite><b>{content[:64]}{'…' if len(content) > 64 else ''}</b></ansiwhite>" if is_active else f"<ansiwhite>{content[:64]}{'…' if len(content) > 64 else ''}</ansiwhite>"
176
+ text_fmt = f"<ansiwhite><b>{safe_preview}</b></ansiwhite>" if is_active else f"<ansiwhite>{safe_preview}</ansiwhite>"
163
177
  lines.append(f" {cursor}{role_fmt} {text_fmt}\n\n")
164
178
  else:
165
179
  # User message — display only, dimmed, no cursor
166
- lines.append(f" <ansibrightblack>[user] {content[:64]}{'…' if len(content) > 64 else ''}</ansibrightblack>\n\n")
180
+ lines.append(f" <ansibrightblack>[user] {safe_preview}</ansibrightblack>\n\n")
167
181
  lines.append(" <ansibrightblack>↑/↓ to navigate · Enter to branch here · Ctrl+C to cancel</ansibrightblack>\n")
168
182
  return HTML("".join(lines))
169
183
 
@@ -248,9 +262,11 @@ async def pick_session(graphs: list[dict[str, Any]]) -> dict[str, Any] | None:
248
262
  lines.append(f" {cursor}{label}\n")
249
263
  else:
250
264
  name = choice.get("name", "Untitled")
265
+ # Graph names are user-controlled; escape before embedding.
266
+ safe_name = _html_escape(name)
251
267
  updated = _relative_time(choice.get("updated_at", ""))
252
268
  active_node = choice.get("active_node")
253
- name_fmt = f"<ansiwhite><b>{name}</b></ansiwhite>" if selected else f"<ansiwhite>{name}</ansiwhite>"
269
+ name_fmt = f"<ansiwhite><b>{safe_name}</b></ansiwhite>" if selected else f"<ansiwhite>{safe_name}</ansiwhite>"
254
270
  time_fmt = f"<ansibrightblack>{updated}</ansibrightblack>" if updated else ""
255
271
  # Line 1: graph name + last active time
256
272
  lines.append(f" {cursor}{name_fmt} {time_fmt}\n")
@@ -258,8 +274,11 @@ async def pick_session(graphs: list[dict[str, Any]]) -> dict[str, Any] | None:
258
274
  if active_node and active_node.get("label"):
259
275
  node_lbl = active_node["label"]
260
276
  truncated = node_lbl[:60] + ("…" if len(node_lbl) > 60 else "")
277
+ # Node labels are assistant-generated and routinely
278
+ # contain HTML-unsafe characters; escape.
279
+ safe_truncated = _html_escape(truncated)
261
280
  node_id_short = active_node["id"][:8]
262
- lines.append(f" <ansibrightblack>↳ resume node {node_id_short} · \"{truncated}\"</ansibrightblack>\n")
281
+ lines.append(f" <ansibrightblack>↳ resume node {node_id_short} · \"{safe_truncated}\"</ansibrightblack>\n")
263
282
  elif active_node:
264
283
  node_id_short = active_node["id"][:8]
265
284
  lines.append(f" <ansibrightblack>↳ resume node {node_id_short} · (untitled)</ansibrightblack>\n")
@@ -168,11 +168,26 @@ class _MarkdownStreamState:
168
168
 
169
169
 
170
170
  # ---------------------------------------------------------------------------
171
- # Module-level spinner control — lets the driver pause the spinner before
172
- # showing a permission prompt so the two don't race on stdout.
171
+ # Module-level pause/resume hooks for interactive UI overlays (permission
172
+ # pickers, in-line confirmations) that need to take over the terminal.
173
+ #
174
+ # Two pieces of in-flight terminal state can collide with a prompt_toolkit
175
+ # Application:
176
+ # 1. The turn-level spinner thread (rewrites the same line).
177
+ # 2. The streaming-markdown rich.Live context, which owns the cursor for
178
+ # partial re-renders. If a picker tries to render while rich.Live is
179
+ # mid-refresh, prompt_toolkit's draw is overwritten on the next Live
180
+ # tick and the picker is invisible to the user — the visible symptom
181
+ # is "the agent went silent" because the user never sees a prompt to
182
+ # approve and the agent is sitting on a permission request.
183
+ #
184
+ # pause_* committs/stops both pieces; resume_* restarts only the spinner
185
+ # (the markdown stream restarts itself the next time a chunk arrives, so
186
+ # there's nothing to resume there).
173
187
  # ---------------------------------------------------------------------------
174
188
 
175
189
  _active_spinner: _Spinner | None = None
190
+ _active_md_stream: _MarkdownStreamState | None = None
176
191
 
177
192
 
178
193
  def pause_active_spinner() -> None:
@@ -185,6 +200,15 @@ def resume_active_spinner() -> None:
185
200
  _active_spinner.resume()
186
201
 
187
202
 
203
+ def pause_active_md_stream() -> None:
204
+ """Flush and stop the streaming markdown rich.Live so a prompt_toolkit
205
+ picker can take over the terminal cleanly. Already-rendered text stays
206
+ committed above; the next text chunk after the picker exits will start
207
+ a fresh Live below it."""
208
+ if _active_md_stream is not None:
209
+ _active_md_stream.flush()
210
+
211
+
188
212
  # ---------------------------------------------------------------------------
189
213
  # TerminalRenderer — main public class
190
214
  # ---------------------------------------------------------------------------
@@ -199,8 +223,14 @@ class TerminalRenderer:
199
223
  """
200
224
 
201
225
  def __init__(self) -> None:
226
+ global _active_md_stream
202
227
  self._spinner: _Spinner | None = None
203
228
  self._md = _MarkdownStreamState()
229
+ # Register the markdown stream so pause_active_md_stream() can
230
+ # flush it ahead of any interactive picker. The renderer is a
231
+ # singleton per session, so this assignment is safe to leave in
232
+ # place for the lifetime of the process.
233
+ _active_md_stream = self._md
204
234
  self._spinner_active = False
205
235
 
206
236
  # ------------------------------------------------------------------
@@ -37,6 +37,7 @@ from .drivers.acp import ACPDriver, AgentLaunchError
37
37
  from .drivers.claude import ClaudeDriver
38
38
  from .picker import pick_session, pick_node, pick_message
39
39
  from .renderer import TerminalRenderer, pause_active_spinner, resume_active_spinner
40
+ from .snapshot import SnapshotManager
40
41
 
41
42
  logger = logging.getLogger(__name__)
42
43
  console = Console()
@@ -128,6 +129,24 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
128
129
  await backend.aclose()
129
130
  return
130
131
 
132
+ # ----------------------------------------------------------------
133
+ # World-state snapshots — shadow git repo for filesystem state per graph.
134
+ # One shadow repo per graph_id (not per project path) so explicit "+ Start
135
+ # new session" gives a clean filesystem slate too. Resuming the same graph
136
+ # reuses the same shadow.
137
+ # ----------------------------------------------------------------
138
+ snapshot_manager: SnapshotManager | None = None
139
+ try:
140
+ snapshot_manager = SnapshotManager(cwd, graph_id)
141
+ await snapshot_manager.init()
142
+ except Exception:
143
+ # Snapshot subsystem is best-effort — never block the session on it.
144
+ # Common reasons it fails: git not on PATH, cwd not writable, broken
145
+ # shadow repo. Log loudly but continue without snapshots; /branch
146
+ # and /checkout will fall back to conversation-only forking.
147
+ logger.exception("SnapshotManager: init failed — snapshots disabled this session")
148
+ snapshot_manager = None
149
+
131
150
  # ----------------------------------------------------------------
132
151
  # Wire up event capture
133
152
  # ----------------------------------------------------------------
@@ -135,6 +154,7 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
135
154
  backend=backend,
136
155
  graph_id=graph_id,
137
156
  node_id=node_id,
157
+ snapshot_manager=snapshot_manager,
138
158
  )
139
159
 
140
160
  # Shared state for branch switching — written by WS listener, read by REPL
@@ -238,10 +258,20 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
238
258
  _toolbar_style = Style.from_dict({'bottom-toolbar': 'bg:default fg:ansibrightblack noreverse'})
239
259
 
240
260
  def _toolbar() -> HTML:
261
+ # Node labels are user prompts and assistant-generated summaries —
262
+ # both routinely contain `&`, `<`, `>` that would crash prompt_toolkit's
263
+ # HTML parser. Escape before embedding. Otherwise the toolbar redraws
264
+ # on every keystroke, hits the ExpatError, and prompts the user with
265
+ # "Press ENTER to continue…" indefinitely.
266
+ from html import escape as _html_escape
241
267
  label = current_node_label['value']
242
268
  status = current_node_status['value']
243
269
  node_short = capture.node_id[:8] if capture.node_id else '--------'
244
- label_part = f" · {label[:40]}{'…' if label and len(label) > 40 else ''}" if label else ""
270
+ if label:
271
+ preview = label[:40] + ('…' if len(label) > 40 else '')
272
+ label_part = f" · {_html_escape(preview)}"
273
+ else:
274
+ label_part = ""
245
275
  status_badge = " [branched]" if status == 'branched' else ""
246
276
  border = '─' * 200 # prompt_toolkit clips to terminal width
247
277
  return HTML(
@@ -263,6 +293,52 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
263
293
  else:
264
294
  await driver.connect(cwd=cwd)
265
295
 
296
+ async def _restore_fs_snapshot(node_data: dict) -> None:
297
+ """
298
+ Best-effort restore of the work-tree to a node's filesystem snapshot.
299
+
300
+ Surfaces dirty-tree status to the user before the rewind: if the
301
+ current work-tree has uncommitted edits that will be auto-
302
+ preserved, we tell them inline so they understand the rewind
303
+ isn't silently destroying anything. After the restore we echo
304
+ the rescue commit hash so the user has a handle to recover
305
+ from if they ever want those edits back.
306
+
307
+ Logs and continues on any failure — we never want a snapshot
308
+ issue to block conversation-side branching/checkout, which is
309
+ still partially useful even with FS divergence.
310
+ """
311
+ if snapshot_manager is None:
312
+ return
313
+ git_hash = (node_data.get("git_hash") or "").strip()
314
+ if not git_hash:
315
+ # Node was created before snapshots existed, or the source
316
+ # node had no FS changes captured. Nothing to restore.
317
+ return
318
+
319
+ # Pre-restore: tell the user if pending edits will be auto-
320
+ # preserved. Best-effort — never block the restore on this.
321
+ try:
322
+ if await snapshot_manager.has_uncommitted_changes():
323
+ console.print(
324
+ "[dim yellow] ⚠ Saving uncommitted edits as a rescue snapshot before rewinding…[/dim yellow]"
325
+ )
326
+ except Exception:
327
+ logger.debug("snapshot: dirty-tree check failed", exc_info=True)
328
+
329
+ try:
330
+ rescue_hash = await snapshot_manager.restore(git_hash)
331
+ logger.debug("snapshot: restored FS to %s", git_hash[:8])
332
+ if rescue_hash:
333
+ console.print(
334
+ f"[dim] edits preserved as snapshot [bold]{rescue_hash[:8]}[/bold] in shadow git[/dim]"
335
+ )
336
+ except Exception:
337
+ logger.exception(
338
+ "snapshot: failed to restore FS to %s — continuing without rewind",
339
+ git_hash[:8],
340
+ )
341
+
266
342
  async def _handle_pending_switch(*, print_notification: bool = False) -> None:
267
343
  """
268
344
  Handle a pending branch switch if one is queued.
@@ -288,6 +364,9 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
288
364
  if fetched_label:
289
365
  current_node_label['value'] = fetched_label
290
366
  node_label_cache[new_node_id] = fetched_label
367
+ # Rewind the work-tree to this node's snapshot before the
368
+ # agent restart — agent and FS need to agree on state.
369
+ await _restore_fs_snapshot(result)
291
370
  new_cc_session_id = await driver.reset_context(
292
371
  result.get("materialized_context", []),
293
372
  cc_session_id=result.get("cc_session_id") or None,
@@ -332,6 +411,11 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
332
411
  child_checkout = await backend.checkout_node(child_id)
333
412
  current_node_status['value'] = child_checkout.get("status", "active")
334
413
  current_node_label['value'] = child_checkout.get("label") or None
414
+ # Rewind work-tree to the snapshot at the chosen branch
415
+ # point. Backend's branch_node populated child.git_hash by
416
+ # walking parent's events to the right index — see
417
+ # backend/nodes/views.py branch_node().
418
+ await _restore_fs_snapshot(child_checkout)
335
419
  new_cc_session_id = await driver.reset_context(
336
420
  child_checkout.get("materialized_context", []),
337
421
  cc_session_id=child_checkout.get("cc_session_id") or None,
@@ -402,6 +486,8 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
402
486
  if fetched_label:
403
487
  node_label_cache[target_id] = fetched_label
404
488
 
489
+ # Rewind work-tree to target node's snapshot before agent restart.
490
+ await _restore_fs_snapshot(node_data)
405
491
  new_cc_session_id = await driver.reset_context(
406
492
  node_data.get("materialized_context", []),
407
493
  cc_session_id=node_data.get("cc_session_id") or None,
@@ -456,6 +542,8 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
456
542
  # Force active — user explicitly reverted, bypass the branched-node guard
457
543
  current_node_status['value'] = 'active'
458
544
  current_node_label['value'] = parent_data.get("label") or None
545
+ # Rewind FS to parent's snapshot before agent restart.
546
+ await _restore_fs_snapshot(parent_data)
459
547
  new_cc_session_id = await driver.reset_context(
460
548
  parent_data.get("materialized_context", []),
461
549
  cc_session_id=parent_data.get("cc_session_id") or None,
@@ -510,6 +598,8 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
510
598
  child_checkout = await backend.checkout_node(child_id)
511
599
  current_node_status['value'] = child_checkout.get("status", "active")
512
600
  current_node_label['value'] = child_checkout.get("label") or None
601
+ # Rewind FS to the new child's inherited snapshot before agent restart.
602
+ await _restore_fs_snapshot(child_checkout)
513
603
  new_cc_session_id = await driver.reset_context(
514
604
  child_checkout.get("materialized_context", []),
515
605
  cc_session_id=child_checkout.get("cc_session_id") or None,
@@ -705,6 +795,24 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
705
795
  await backend.patch_node(capture.node_id, {"cc_session_id": cc_session_id})
706
796
  except Exception:
707
797
  logger.warning("Failed to store cc_session_id for node %s", capture.node_id)
798
+
799
+ # Take a final snapshot at turn end and pin it as the
800
+ # node's git_hash. Catches any user-side or final-tool
801
+ # FS changes that happened after the last hook fired
802
+ # so /branch and /checkout always have a concrete state
803
+ # to rewind to. No-op if nothing changed.
804
+ if snapshot_manager is not None:
805
+ try:
806
+ snap_hash, _ = await snapshot_manager.commit(
807
+ f"node {capture.node_id[:8]} turn end"
808
+ )
809
+ if snap_hash:
810
+ try:
811
+ await backend.patch_node(capture.node_id, {"git_hash": snap_hash})
812
+ except Exception:
813
+ logger.warning("Failed to persist git_hash for node %s", capture.node_id)
814
+ except Exception:
815
+ logger.exception("turn-end snapshot commit failed")
708
816
  # If auto-branch happened mid-stream, reset driver context to child node now
709
817
  # that the stream is fully done (can't disconnect/reconnect mid-stream).
710
818
  # The pivot user message already lives on the child via the in-stream
@@ -0,0 +1,482 @@
1
+ """
2
+ World-state snapshots — shadow git repo capturing filesystem state per tool call.
3
+
4
+ The product promise is "graph layer on top of agentic AI tools." If `/branch`
5
+ doesn't actually rewind the filesystem when forking the conversation, that
6
+ promise is incomplete. SnapshotManager closes that gap by tagging every
7
+ side-effecting tool call with a git commit and rewinding the work-tree on
8
+ `/branch` and `/checkout`.
9
+
10
+ Architecture
11
+ ------------
12
+ We use git's `--git-dir` / `--work-tree` separation. The shadow repo's `.git`
13
+ lives at `~/.expluro/snapshots/<graph-id>/.git`, where `graph-id` is the
14
+ backend's UUID for the graph (one per "topology graph session" in the
15
+ user's mental model). Multiple graphs in the same project directory each
16
+ get their own isolated shadow — explicit "+ Start new session" means a
17
+ fresh slate at the filesystem level too.
18
+
19
+ The work-tree is the user's actual project directory. Two consequences:
20
+
21
+ 1. The user's own `.git` is never touched. Their repo state, their staged
22
+ index, their stash — all untouched. Their normal git workflow is
23
+ unaffected.
24
+ 2. Non-git projects work transparently. A project that's just loose files
25
+ gets snapshots without the user even initializing git.
26
+
27
+ We respect the user's `.gitignore` (shadow git reads it from the work-tree),
28
+ so build artifacts, `node_modules`, and friends stay out of snapshot history.
29
+
30
+ Edge cases handled
31
+ ------------------
32
+ - Binary files: git handles natively (hashed, no text diff).
33
+ - Large files: respected via `.gitignore` if user excludes them; otherwise
34
+ we cap diff text reported in events at SNAPSHOT_DIFF_MAX_BYTES.
35
+ - User edits between tool calls: commit captures all current work-tree
36
+ state, so manual edits get rolled into the next snapshot.
37
+ - `/branch` with uncommitted vs-last-snapshot work: we auto-commit those as
38
+ a "user-edits" snapshot before the restore so nothing is lost.
39
+ - User's git hooks: shadow commits use `--no-verify` to skip them — the
40
+ shadow repo is a tool-internal concern, not a code-review concern.
41
+ - Concurrent operations: each call awaits its subprocess to completion,
42
+ no parallel git operations on the same dir.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import asyncio
47
+ import logging
48
+ import os
49
+ import shutil
50
+ from pathlib import Path
51
+ from typing import Optional
52
+
53
+ from .constants import APP_DIR
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # Cap diff text reported in events. Full diffs for large changes blow up
58
+ # the event log and don't render meaningfully in the browser anyway —
59
+ # truncate and let users fetch the full snapshot via the CLI later.
60
+ SNAPSHOT_DIFF_MAX_BYTES = 50_000
61
+
62
+ SNAPSHOTS_ROOT = Path.home() / APP_DIR / "snapshots"
63
+
64
+
65
+ class SnapshotError(RuntimeError):
66
+ """Raised when a snapshot operation fails in a way the caller should know about."""
67
+
68
+
69
+ class SnapshotManager:
70
+ """Shadow-git snapshot manager scoped to a single graph (topology session).
71
+
72
+ Each graph gets its own shadow repo — explicit "+ Start new session"
73
+ means a clean slate at the filesystem level too. Cleanup is natural:
74
+ deleting a graph maps 1:1 to deleting its shadow directory.
75
+
76
+ Lifecycle:
77
+ sm = SnapshotManager(cwd, graph_id)
78
+ await sm.init() # ensure shadow repo exists
79
+ hash, diff = await sm.commit(label) # snapshot current state
80
+ await sm.restore(hash) # rewind work-tree to that snapshot
81
+ # no explicit close needed — shadow repo persists across sessions
82
+ # for the SAME graph (resume preserves history); deleted with the graph
83
+ """
84
+
85
+ def __init__(self, cwd: str, graph_id: str) -> None:
86
+ if not os.path.isdir(cwd):
87
+ raise SnapshotError(f"cwd is not a directory: {cwd}")
88
+ if not graph_id:
89
+ raise SnapshotError("graph_id is required for SnapshotManager")
90
+ self._cwd = os.path.realpath(cwd)
91
+ self._graph_id = str(graph_id)
92
+ self._git_dir = SNAPSHOTS_ROOT / self._graph_id / ".git"
93
+ self._initialized = False
94
+
95
+ @property
96
+ def graph_id(self) -> str:
97
+ return self._graph_id
98
+
99
+ @property
100
+ def git_dir(self) -> Path:
101
+ return self._git_dir
102
+
103
+ # ------------------------------------------------------------------
104
+ # Lifecycle
105
+ # ------------------------------------------------------------------
106
+
107
+ async def init(self) -> None:
108
+ """Ensure the shadow repo exists and has at least an initial commit.
109
+
110
+ Idempotent — safe to call on every session start.
111
+ """
112
+ if self._initialized:
113
+ return
114
+
115
+ SNAPSHOTS_ROOT.mkdir(parents=True, exist_ok=True)
116
+
117
+ # Already initialized?
118
+ if (self._git_dir / "HEAD").exists():
119
+ self._initialized = True
120
+ logger.debug("SnapshotManager: shadow repo already exists at %s", self._git_dir)
121
+ return
122
+
123
+ # Create the shadow repo. We do NOT init the work-tree here —
124
+ # `git --git-dir=X --work-tree=Y init` would point HEAD into Y
125
+ # which is fine, but we want to be explicit about which dir the
126
+ # repo metadata lives in.
127
+ self._git_dir.parent.mkdir(parents=True, exist_ok=True)
128
+ await self._git("init", "--quiet", "--bare=false", create_anyway=True)
129
+
130
+ # Configure shadow repo identity — needed for commits to succeed
131
+ # without leaning on whatever the user has globally configured.
132
+ await self._git("config", "user.name", "Expluro Snapshot")
133
+ await self._git("config", "user.email", "snapshots@expluro.local")
134
+ # Don't sign shadow commits even if user has commit.gpgsign=true
135
+ # globally — we don't want to spam the user with gpg prompts.
136
+ await self._git("config", "commit.gpgsign", "false")
137
+
138
+ # Initial baseline commit. May be empty if cwd is empty — git
139
+ # accepts empty-tree commits with --allow-empty.
140
+ await self._git("add", "--all", "--", ".")
141
+ await self._git(
142
+ "commit",
143
+ "--allow-empty",
144
+ "--no-verify",
145
+ "--quiet",
146
+ "-m",
147
+ "expluro: initial snapshot",
148
+ )
149
+
150
+ self._initialized = True
151
+ logger.info("SnapshotManager: initialized shadow repo at %s", self._git_dir)
152
+
153
+ # ------------------------------------------------------------------
154
+ # Capture
155
+ # ------------------------------------------------------------------
156
+
157
+ async def commit(self, label: str) -> tuple[Optional[str], Optional[dict]]:
158
+ """Commit the current work-tree state to the shadow repo.
159
+
160
+ Returns (commit_hash, diff_summary). If there are no changes since
161
+ the last commit, returns (existing_HEAD, None) so the caller still
162
+ has a hash to attach to the event.
163
+
164
+ diff_summary is a dict suitable for embedding in an event payload:
165
+ {
166
+ "files_changed": [{"path": str, "status": str}, ...],
167
+ "insertions": int,
168
+ "deletions": int,
169
+ "diff_text": str, # truncated to SNAPSHOT_DIFF_MAX_BYTES
170
+ "truncated": bool,
171
+ }
172
+ """
173
+ if not self._initialized:
174
+ await self.init()
175
+
176
+ # Stage everything new/modified/deleted that isn't gitignored.
177
+ await self._git("add", "--all", "--", ".")
178
+
179
+ # Has anything actually changed since HEAD?
180
+ has_changes = await self._has_staged_changes()
181
+ if not has_changes:
182
+ head = await self._git_head()
183
+ return head, None
184
+
185
+ # Capture diff BEFORE committing so we can describe what changed.
186
+ diff_summary = await self._diff_summary_staged()
187
+
188
+ sanitized = label.replace("\n", " ").strip()[:200] or "snapshot"
189
+ await self._git(
190
+ "commit",
191
+ "--no-verify",
192
+ "--quiet",
193
+ "-m",
194
+ f"expluro: {sanitized}",
195
+ )
196
+
197
+ commit_hash = await self._git_head()
198
+ logger.debug(
199
+ "SnapshotManager: committed %s (%d files, +%d/-%d)",
200
+ commit_hash[:8] if commit_hash else "?",
201
+ len(diff_summary.get("files_changed", [])),
202
+ diff_summary.get("insertions", 0),
203
+ diff_summary.get("deletions", 0),
204
+ )
205
+ return commit_hash, diff_summary
206
+
207
+ # ------------------------------------------------------------------
208
+ # Restore
209
+ # ------------------------------------------------------------------
210
+
211
+ async def has_uncommitted_changes(self) -> bool:
212
+ """Return True if the work-tree differs from the shadow repo's HEAD.
213
+
214
+ Used to inform the user before a destructive `restore()` that their
215
+ in-flight edits will be auto-preserved as a rescue snapshot. Never
216
+ raises — a git failure is treated as "clean" so this can't block
217
+ the restore path on transient issues.
218
+ """
219
+ if not self._initialized:
220
+ return False
221
+ try:
222
+ out = await self._git("status", "--porcelain")
223
+ except SnapshotError:
224
+ return False
225
+ return bool(out.strip())
226
+
227
+ async def restore(self, commit_hash: str) -> Optional[str]:
228
+ """Rewind the work-tree to the given snapshot.
229
+
230
+ Auto-commits any pending uncommitted-vs-last-snapshot work first so
231
+ nothing is lost. The auto-commit is reachable from the shadow repo's
232
+ reflog and we return its hash so the caller can surface it to the
233
+ user as a recovery handle.
234
+
235
+ Returns the rescue commit hash when the auto-preserve actually
236
+ happened (i.e. the work-tree was dirty), or None when there was
237
+ nothing to preserve.
238
+
239
+ Raises SnapshotError if the target commit doesn't exist or the
240
+ reset fails.
241
+ """
242
+ if not self._initialized:
243
+ await self.init()
244
+
245
+ if not commit_hash:
246
+ raise SnapshotError("restore() called with empty commit hash")
247
+
248
+ # Verify the hash exists in our shadow repo.
249
+ try:
250
+ await self._git("cat-file", "-e", f"{commit_hash}^{{commit}}")
251
+ except SnapshotError as exc:
252
+ raise SnapshotError(
253
+ f"snapshot {commit_hash[:8]} not found in shadow repo"
254
+ ) from exc
255
+
256
+ # Auto-commit any pending work to preserve it before we rewind.
257
+ # Most of the time this is a no-op (the last tool call already
258
+ # committed). It matters when the user has manually edited files
259
+ # between turns, or when a turn's snapshot didn't catch the very
260
+ # last write.
261
+ rescue_hash: Optional[str] = None
262
+ await self._git("add", "--all", "--", ".")
263
+ if await self._has_staged_changes():
264
+ await self._git(
265
+ "commit",
266
+ "--no-verify",
267
+ "--quiet",
268
+ "-m",
269
+ "expluro: auto-preserve before restore",
270
+ )
271
+ rescue_hash = await self._git_head()
272
+ logger.info(
273
+ "SnapshotManager: preserved pre-restore changes as %s",
274
+ (rescue_hash or "?")[:8],
275
+ )
276
+
277
+ # Hard-reset the work-tree to the target commit. We use reset
278
+ # rather than checkout because we want the index AND the work-tree
279
+ # to match the target — checkout in detached-HEAD mode plays
280
+ # weirdly with the user's normal git workflow.
281
+ await self._git("reset", "--hard", "--quiet", commit_hash)
282
+ logger.info("SnapshotManager: restored to %s", commit_hash[:8])
283
+ return rescue_hash
284
+
285
+ # ------------------------------------------------------------------
286
+ # Diff helpers
287
+ # ------------------------------------------------------------------
288
+
289
+ async def diff_against_head(self, commit_hash: str) -> Optional[dict]:
290
+ """Return a diff summary comparing the given commit against current HEAD.
291
+
292
+ Used by the browser node detail panel to render "what changed at this
293
+ node" without us having to ship a separate diff API.
294
+ """
295
+ if not self._initialized:
296
+ await self.init()
297
+ if not commit_hash:
298
+ return None
299
+
300
+ try:
301
+ return await self._diff_summary_between(f"{commit_hash}~1", commit_hash)
302
+ except SnapshotError as exc:
303
+ logger.warning("diff_against_head failed: %s", exc)
304
+ return None
305
+
306
+ # ------------------------------------------------------------------
307
+ # Internal: git plumbing
308
+ # ------------------------------------------------------------------
309
+
310
+ async def _git(
311
+ self,
312
+ *args: str,
313
+ create_anyway: bool = False,
314
+ ) -> str:
315
+ """
316
+ Run a git command in the shadow repo. Returns stdout.
317
+
318
+ `create_anyway` is for `init` itself — git init doesn't need
319
+ --git-dir/--work-tree the same way.
320
+ """
321
+ # GIT_DIR + GIT_WORK_TREE env separates the shadow repo from the
322
+ # user's. Setting via env (not flags) lets `git init` and friends
323
+ # behave correctly.
324
+ env = os.environ.copy()
325
+ env["GIT_DIR"] = str(self._git_dir)
326
+ env["GIT_WORK_TREE"] = self._cwd
327
+ # Avoid leaning on user's per-repo or global config that might
328
+ # interfere (e.g. signing, commit templates, hooks paths).
329
+ env.pop("GIT_CONFIG_GLOBAL", None)
330
+ env.pop("GIT_CONFIG_SYSTEM", None)
331
+
332
+ # `git init` needs the parent dir to exist; its own positional arg
333
+ # is the GIT_DIR location.
334
+ if args and args[0] == "init":
335
+ cmd = ["git", "init", "--quiet", str(self._git_dir)]
336
+ else:
337
+ cmd = ["git", *args]
338
+
339
+ try:
340
+ proc = await asyncio.create_subprocess_exec(
341
+ *cmd,
342
+ stdin=asyncio.subprocess.DEVNULL,
343
+ stdout=asyncio.subprocess.PIPE,
344
+ stderr=asyncio.subprocess.PIPE,
345
+ env=env,
346
+ cwd=self._cwd,
347
+ )
348
+ except FileNotFoundError as exc:
349
+ raise SnapshotError(
350
+ "git is not installed or not on PATH — snapshots disabled"
351
+ ) from exc
352
+
353
+ stdout_b, stderr_b = await proc.communicate()
354
+ if proc.returncode != 0:
355
+ raise SnapshotError(
356
+ f"git {' '.join(args)} failed (rc={proc.returncode}): "
357
+ f"{stderr_b.decode(errors='replace').strip()}"
358
+ )
359
+ return stdout_b.decode(errors="replace")
360
+
361
+ async def _git_head(self) -> Optional[str]:
362
+ try:
363
+ out = await self._git("rev-parse", "HEAD")
364
+ return out.strip() or None
365
+ except SnapshotError:
366
+ return None
367
+
368
+ async def _has_staged_changes(self) -> bool:
369
+ # `git diff --cached --quiet` exits 0 if nothing staged, 1 if changes.
370
+ # Run it manually since our _git wrapper raises on non-zero.
371
+ env = os.environ.copy()
372
+ env["GIT_DIR"] = str(self._git_dir)
373
+ env["GIT_WORK_TREE"] = self._cwd
374
+ proc = await asyncio.create_subprocess_exec(
375
+ "git", "diff", "--cached", "--quiet",
376
+ stdin=asyncio.subprocess.DEVNULL,
377
+ stdout=asyncio.subprocess.DEVNULL,
378
+ stderr=asyncio.subprocess.DEVNULL,
379
+ env=env,
380
+ cwd=self._cwd,
381
+ )
382
+ rc = await proc.wait()
383
+ return rc != 0
384
+
385
+ async def _diff_summary_staged(self) -> dict:
386
+ """Build a diff summary for currently-staged changes."""
387
+ files_changed = await self._diff_files_status("--cached")
388
+ numstat = await self._git("diff", "--cached", "--numstat")
389
+ insertions, deletions = _parse_numstat(numstat)
390
+ diff_text = await self._git("diff", "--cached")
391
+ return _build_summary(files_changed, insertions, deletions, diff_text)
392
+
393
+ async def _diff_summary_between(self, a: str, b: str) -> dict:
394
+ files_changed = await self._diff_files_status(a, b)
395
+ numstat = await self._git("diff", "--numstat", a, b)
396
+ insertions, deletions = _parse_numstat(numstat)
397
+ diff_text = await self._git("diff", a, b)
398
+ return _build_summary(files_changed, insertions, deletions, diff_text)
399
+
400
+ async def _diff_files_status(self, *git_args: str) -> list[dict]:
401
+ """Run `git diff --name-status` and parse to list of {path, status}."""
402
+ out = await self._git("diff", "--name-status", *git_args)
403
+ files: list[dict] = []
404
+ for line in out.splitlines():
405
+ parts = line.split("\t")
406
+ if len(parts) < 2:
407
+ continue
408
+ status = parts[0].strip()
409
+ # Renames produce: R100\told\tnew — collapse to just the new path.
410
+ path = parts[-1]
411
+ files.append({"path": path, "status": status[:1]})
412
+ return files
413
+
414
+ # ------------------------------------------------------------------
415
+ # Maintenance
416
+ # ------------------------------------------------------------------
417
+
418
+ @staticmethod
419
+ def shadow_dir_for(graph_id: str) -> Path:
420
+ """Where the shadow repo for this graph lives. Useful for UI display."""
421
+ return SNAPSHOTS_ROOT / str(graph_id)
422
+
423
+ @staticmethod
424
+ def delete_shadow(graph_id: str) -> None:
425
+ """
426
+ Remove the shadow repo for this graph. Used when a graph is deleted
427
+ or on explicit user request. Once deleted, all snapshot hashes for
428
+ that graph's nodes become orphan references with no restore path —
429
+ which is correct behavior when the graph itself is gone.
430
+ """
431
+ shadow = SnapshotManager.shadow_dir_for(graph_id)
432
+ if shadow.exists():
433
+ shutil.rmtree(shadow)
434
+ logger.info("SnapshotManager: deleted shadow for graph %s", graph_id)
435
+
436
+
437
+ # ----------------------------------------------------------------------
438
+ # Module-level helpers
439
+ # ----------------------------------------------------------------------
440
+
441
+
442
+ def _parse_numstat(numstat: str) -> tuple[int, int]:
443
+ """Parse `git diff --numstat` output to total (insertions, deletions).
444
+
445
+ Numstat format: `<insertions>\t<deletions>\t<path>` per line. Binary
446
+ files show `-\t-\t<path>` and don't contribute to the count.
447
+ """
448
+ ins = dele = 0
449
+ for line in numstat.splitlines():
450
+ parts = line.split("\t", 2)
451
+ if len(parts) < 3:
452
+ continue
453
+ a, b = parts[0], parts[1]
454
+ if a == "-" or b == "-":
455
+ continue
456
+ try:
457
+ ins += int(a)
458
+ dele += int(b)
459
+ except ValueError:
460
+ pass
461
+ return ins, dele
462
+
463
+
464
+ def _build_summary(
465
+ files_changed: list[dict],
466
+ insertions: int,
467
+ deletions: int,
468
+ diff_text: str,
469
+ ) -> dict:
470
+ truncated = False
471
+ encoded = diff_text.encode("utf-8", errors="replace")
472
+ if len(encoded) > SNAPSHOT_DIFF_MAX_BYTES:
473
+ truncated = True
474
+ diff_text = encoded[:SNAPSHOT_DIFF_MAX_BYTES].decode("utf-8", errors="replace")
475
+ diff_text += f"\n…[{len(encoded) - SNAPSHOT_DIFF_MAX_BYTES} bytes truncated]\n"
476
+ return {
477
+ "files_changed": files_changed,
478
+ "insertions": insertions,
479
+ "deletions": deletions,
480
+ "diff_text": diff_text,
481
+ "truncated": truncated,
482
+ }
@@ -1,3 +0,0 @@
1
- class SnapshotManager:
2
- # Implemented in task #9
3
- pass
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes