htmlgraph 0.27.7__py3-none-any.whl → 0.28.1__py3-none-any.whl

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 (34) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/api/broadcast.py +316 -0
  3. htmlgraph/api/broadcast_routes.py +357 -0
  4. htmlgraph/api/broadcast_websocket.py +115 -0
  5. htmlgraph/api/cost_alerts_websocket.py +7 -16
  6. htmlgraph/api/main.py +135 -1
  7. htmlgraph/api/offline.py +776 -0
  8. htmlgraph/api/presence.py +446 -0
  9. htmlgraph/api/reactive.py +455 -0
  10. htmlgraph/api/reactive_routes.py +195 -0
  11. htmlgraph/api/static/broadcast-demo.html +393 -0
  12. htmlgraph/api/static/presence-widget-demo.html +785 -0
  13. htmlgraph/api/sync_routes.py +184 -0
  14. htmlgraph/api/templates/partials/agents.html +308 -80
  15. htmlgraph/api/websocket.py +112 -37
  16. htmlgraph/broadcast_integration.py +227 -0
  17. htmlgraph/cli_commands/sync.py +207 -0
  18. htmlgraph/db/schema.py +226 -0
  19. htmlgraph/hooks/event_tracker.py +53 -2
  20. htmlgraph/models.py +1 -0
  21. htmlgraph/reactive_integration.py +148 -0
  22. htmlgraph/session_manager.py +7 -0
  23. htmlgraph/sync/__init__.py +21 -0
  24. htmlgraph/sync/git_sync.py +458 -0
  25. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/METADATA +1 -1
  26. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/RECORD +32 -19
  27. htmlgraph/dashboard.html +0 -6592
  28. htmlgraph-0.27.7.data/data/htmlgraph/dashboard.html +0 -6592
  29. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/styles.css +0 -0
  30. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  31. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  32. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  33. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/WHEEL +0 -0
  34. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/entry_points.txt +0 -0
htmlgraph/db/schema.py CHANGED
@@ -484,6 +484,81 @@ class HtmlGraphDB:
484
484
  )
485
485
  """)
486
486
 
487
+ # 12. AGENT_PRESENCE TABLE - Phase 1: Cross-Agent Presence Tracking
488
+ cursor.execute("""
489
+ CREATE TABLE IF NOT EXISTS agent_presence (
490
+ agent_id TEXT PRIMARY KEY,
491
+ status TEXT NOT NULL DEFAULT 'offline' CHECK(
492
+ status IN ('active', 'idle', 'offline')
493
+ ),
494
+ current_feature_id TEXT,
495
+ last_tool_name TEXT,
496
+ last_activity DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
497
+ total_tools_executed INTEGER DEFAULT 0,
498
+ total_cost_tokens INTEGER DEFAULT 0,
499
+ session_id TEXT,
500
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
501
+ FOREIGN KEY (current_feature_id) REFERENCES features(id) ON DELETE SET NULL,
502
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE SET NULL
503
+ )
504
+ """)
505
+
506
+ # 13. OFFLINE_EVENTS TABLE - Phase 4: Offline-First Merge
507
+ cursor.execute("""
508
+ CREATE TABLE IF NOT EXISTS offline_events (
509
+ event_id TEXT PRIMARY KEY,
510
+ agent_id TEXT NOT NULL,
511
+ resource_id TEXT NOT NULL,
512
+ resource_type TEXT NOT NULL,
513
+ operation TEXT NOT NULL CHECK(
514
+ operation IN ('create', 'update', 'delete')
515
+ ),
516
+ timestamp TEXT NOT NULL,
517
+ payload TEXT NOT NULL,
518
+ status TEXT DEFAULT 'local_only' CHECK(
519
+ status IN ('local_only', 'synced', 'conflict', 'resolved')
520
+ ),
521
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
522
+ )
523
+ """)
524
+
525
+ # 14. CONFLICT_LOG TABLE - Phase 4: Conflict tracking and resolution
526
+ cursor.execute("""
527
+ CREATE TABLE IF NOT EXISTS conflict_log (
528
+ conflict_id TEXT PRIMARY KEY,
529
+ local_event_id TEXT NOT NULL,
530
+ remote_event_id TEXT,
531
+ resource_id TEXT NOT NULL,
532
+ conflict_type TEXT NOT NULL,
533
+ local_timestamp TEXT NOT NULL,
534
+ remote_timestamp TEXT NOT NULL,
535
+ resolution_strategy TEXT NOT NULL,
536
+ resolution TEXT,
537
+ status TEXT DEFAULT 'pending_review' CHECK(
538
+ status IN ('pending_review', 'resolved')
539
+ ),
540
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
541
+ FOREIGN KEY (local_event_id) REFERENCES offline_events(event_id) ON DELETE CASCADE
542
+ )
543
+ """)
544
+
545
+ # 15. SYNC_OPERATIONS TABLE - Phase 5: Git sync tracking
546
+ cursor.execute("""
547
+ CREATE TABLE IF NOT EXISTS sync_operations (
548
+ sync_id TEXT PRIMARY KEY,
549
+ operation TEXT NOT NULL CHECK(operation IN ('push', 'pull')),
550
+ status TEXT NOT NULL CHECK(
551
+ status IN ('idle', 'pushing', 'pulling', 'success', 'error', 'conflict')
552
+ ),
553
+ timestamp DATETIME NOT NULL,
554
+ files_changed INTEGER DEFAULT 0,
555
+ conflicts TEXT,
556
+ message TEXT,
557
+ hostname TEXT,
558
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
559
+ )
560
+ """)
561
+
487
562
  # 9. Create indexes for performance
488
563
  self._create_indexes(cursor)
489
564
 
@@ -590,6 +665,34 @@ class HtmlGraphDB:
590
665
  "CREATE INDEX IF NOT EXISTS idx_cost_events_severity ON cost_events(severity, timestamp DESC)",
591
666
  # Pattern: Timestamp range queries for predictions
592
667
  "CREATE INDEX IF NOT EXISTS idx_cost_events_timestamp ON cost_events(timestamp DESC)",
668
+ # agent_presence indexes - optimized for presence queries
669
+ # Pattern: WHERE status (filter by status)
670
+ "CREATE INDEX IF NOT EXISTS idx_agent_presence_status ON agent_presence(status, last_activity DESC)",
671
+ # Pattern: WHERE current_feature_id (agents working on feature)
672
+ "CREATE INDEX IF NOT EXISTS idx_agent_presence_feature ON agent_presence(current_feature_id, last_activity DESC)",
673
+ # Pattern: ORDER BY last_activity (recent activity)
674
+ "CREATE INDEX IF NOT EXISTS idx_agent_presence_activity ON agent_presence(last_activity DESC)",
675
+ # offline_events indexes - optimized for sync and conflict detection
676
+ # Pattern: WHERE status = 'local_only' (unsynced events)
677
+ "CREATE INDEX IF NOT EXISTS idx_offline_events_status ON offline_events(status, timestamp DESC)",
678
+ # Pattern: WHERE resource_id (detect conflicts)
679
+ "CREATE INDEX IF NOT EXISTS idx_offline_events_resource ON offline_events(resource_id, resource_type)",
680
+ # Pattern: WHERE agent_id (agent's offline work)
681
+ "CREATE INDEX IF NOT EXISTS idx_offline_events_agent ON offline_events(agent_id, timestamp DESC)",
682
+ # conflict_log indexes - optimized for conflict management
683
+ # Pattern: WHERE status = 'pending_review' (unresolved conflicts)
684
+ "CREATE INDEX IF NOT EXISTS idx_conflict_log_status ON conflict_log(status, created_at DESC)",
685
+ # Pattern: WHERE resource_id (conflicts for resource)
686
+ "CREATE INDEX IF NOT EXISTS idx_conflict_log_resource ON conflict_log(resource_id, created_at DESC)",
687
+ # Pattern: WHERE local_event_id (lookup by event)
688
+ "CREATE INDEX IF NOT EXISTS idx_conflict_log_local_event ON conflict_log(local_event_id)",
689
+ # sync_operations indexes - optimized for sync history queries
690
+ # Pattern: WHERE status ORDER BY timestamp DESC (recent sync status)
691
+ "CREATE INDEX IF NOT EXISTS idx_sync_operations_status ON sync_operations(status, timestamp DESC)",
692
+ # Pattern: WHERE operation ORDER BY timestamp DESC (push/pull history)
693
+ "CREATE INDEX IF NOT EXISTS idx_sync_operations_operation ON sync_operations(operation, timestamp DESC)",
694
+ # Pattern: ORDER BY timestamp DESC (recent activity)
695
+ "CREATE INDEX IF NOT EXISTS idx_sync_operations_timestamp ON sync_operations(timestamp DESC)",
593
696
  ]
594
697
 
595
698
  for index_sql in indexes:
@@ -683,6 +786,18 @@ class HtmlGraphDB:
683
786
  )
684
787
  # Re-enable foreign key constraints
685
788
  cursor.execute("PRAGMA foreign_keys=ON")
789
+
790
+ # Update session metadata counters
791
+ cursor.execute(
792
+ """
793
+ UPDATE sessions
794
+ SET total_events = total_events + 1,
795
+ total_tokens_used = total_tokens_used + ?
796
+ WHERE session_id = ?
797
+ """,
798
+ (cost_tokens, session_id),
799
+ )
800
+
686
801
  self.connection.commit() # type: ignore[union-attr]
687
802
  return True
688
803
  except sqlite3.IntegrityError as e:
@@ -1783,6 +1898,117 @@ class HtmlGraphDB:
1783
1898
  logger.error(f"Error querying subagent work: {e}")
1784
1899
  return {}
1785
1900
 
1901
+ def insert_sync_operation(
1902
+ self,
1903
+ sync_id: str,
1904
+ operation: str,
1905
+ status: str,
1906
+ timestamp: str,
1907
+ files_changed: int = 0,
1908
+ conflicts: list[str] | None = None,
1909
+ message: str | None = None,
1910
+ hostname: str | None = None,
1911
+ ) -> bool:
1912
+ """
1913
+ Record a sync operation in the database.
1914
+
1915
+ Args:
1916
+ sync_id: Unique sync operation ID
1917
+ operation: Operation type (push, pull)
1918
+ status: Sync status (idle, pushing, pulling, success, error, conflict)
1919
+ timestamp: Operation timestamp
1920
+ files_changed: Number of files changed
1921
+ conflicts: List of conflicted files (optional)
1922
+ message: Status message (optional)
1923
+ hostname: Hostname that performed the sync (optional)
1924
+
1925
+ Returns:
1926
+ True if insert successful, False otherwise
1927
+ """
1928
+ if not self.connection:
1929
+ self.connect()
1930
+
1931
+ try:
1932
+ cursor = self.connection.cursor() # type: ignore[union-attr]
1933
+ cursor.execute(
1934
+ """
1935
+ INSERT INTO sync_operations
1936
+ (sync_id, operation, status, timestamp, files_changed, conflicts,
1937
+ message, hostname)
1938
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1939
+ """,
1940
+ (
1941
+ sync_id,
1942
+ operation,
1943
+ status,
1944
+ timestamp,
1945
+ files_changed,
1946
+ json.dumps(conflicts) if conflicts else None,
1947
+ message,
1948
+ hostname,
1949
+ ),
1950
+ )
1951
+ self.connection.commit() # type: ignore[union-attr]
1952
+ return True
1953
+ except sqlite3.Error as e:
1954
+ logger.error(f"Error inserting sync operation: {e}")
1955
+ return False
1956
+
1957
+ def get_sync_operations(
1958
+ self, limit: int = 100, operation: str | None = None
1959
+ ) -> list[dict[str, Any]]:
1960
+ """
1961
+ Get recent sync operations.
1962
+
1963
+ Args:
1964
+ limit: Maximum number of results
1965
+ operation: Filter by operation type (optional)
1966
+
1967
+ Returns:
1968
+ List of sync operation dictionaries
1969
+ """
1970
+ if not self.connection:
1971
+ self.connect()
1972
+
1973
+ try:
1974
+ cursor = self.connection.cursor() # type: ignore[union-attr]
1975
+
1976
+ if operation:
1977
+ cursor.execute(
1978
+ """
1979
+ SELECT * FROM sync_operations
1980
+ WHERE operation = ?
1981
+ ORDER BY timestamp DESC
1982
+ LIMIT ?
1983
+ """,
1984
+ (operation, limit),
1985
+ )
1986
+ else:
1987
+ cursor.execute(
1988
+ """
1989
+ SELECT * FROM sync_operations
1990
+ ORDER BY timestamp DESC
1991
+ LIMIT ?
1992
+ """,
1993
+ (limit,),
1994
+ )
1995
+
1996
+ rows = cursor.fetchall()
1997
+ results = []
1998
+ for row in rows:
1999
+ row_dict = dict(row)
2000
+ # Parse JSON conflicts
2001
+ if row_dict.get("conflicts"):
2002
+ try:
2003
+ row_dict["conflicts"] = json.loads(row_dict["conflicts"])
2004
+ except json.JSONDecodeError:
2005
+ pass
2006
+ results.append(row_dict)
2007
+ return results
2008
+ except sqlite3.Error as e:
2009
+ logger.error(f"Error querying sync operations: {e}")
2010
+ return []
2011
+
1786
2012
  def close(self) -> None:
1787
2013
  """Clean up database connection."""
1788
2014
  self.disconnect()
@@ -34,6 +34,25 @@ from htmlgraph.db.schema import HtmlGraphDB
34
34
  from htmlgraph.ids import generate_id
35
35
  from htmlgraph.session_manager import SessionManager
36
36
 
37
+ # Global presence manager instance (initialized on first use)
38
+ _presence_manager = None
39
+
40
+
41
+ def get_presence_manager() -> Any:
42
+ """Get or create global PresenceManager instance."""
43
+ global _presence_manager
44
+ if _presence_manager is None:
45
+ try:
46
+ from htmlgraph.api.presence import PresenceManager
47
+ from htmlgraph.config import get_database_path
48
+
49
+ _presence_manager = PresenceManager(db_path=str(get_database_path()))
50
+ except Exception as e:
51
+ logger.warning(f"Could not initialize PresenceManager: {e}")
52
+ _presence_manager = None
53
+ return _presence_manager
54
+
55
+
37
56
  # Drift classification queue (stored in session directory)
38
57
  DRIFT_QUEUE_FILE = "drift-queue.json"
39
58
 
@@ -1011,6 +1030,11 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
1011
1030
  model=detected_model,
1012
1031
  feature_id=result.feature_id if result else None,
1013
1032
  )
1033
+
1034
+ # Update presence - mark as offline
1035
+ presence_mgr = get_presence_manager()
1036
+ if presence_mgr:
1037
+ presence_mgr.mark_offline(detected_agent)
1014
1038
  except Exception as e:
1015
1039
  logger.warning(f"Warning: Could not track stop: {e}")
1016
1040
  return {"continue": True}
@@ -1031,7 +1055,7 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
1031
1055
  # UserQuery event is stored in database - no file-based state needed
1032
1056
  # Subsequent tool calls query database for parent via get_parent_user_query()
1033
1057
  if db:
1034
- record_event_to_sqlite(
1058
+ event_id = record_event_to_sqlite(
1035
1059
  db=db,
1036
1060
  session_id=active_session_id,
1037
1061
  tool_name="UserQuery",
@@ -1043,6 +1067,19 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
1043
1067
  feature_id=result.feature_id if result else None,
1044
1068
  )
1045
1069
 
1070
+ # Update presence
1071
+ presence_mgr = get_presence_manager()
1072
+ if presence_mgr and event_id:
1073
+ presence_mgr.update_presence(
1074
+ agent_id=detected_agent,
1075
+ event={
1076
+ "tool_name": "UserQuery",
1077
+ "session_id": active_session_id,
1078
+ "feature_id": result.feature_id if result else None,
1079
+ "event_id": event_id,
1080
+ },
1081
+ )
1082
+
1046
1083
  except Exception as e:
1047
1084
  logger.warning(f"Warning: Could not track query: {e}")
1048
1085
  return {"continue": True}
@@ -1176,7 +1213,7 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
1176
1213
  "subagent_type", "general-purpose"
1177
1214
  )
1178
1215
 
1179
- record_event_to_sqlite(
1216
+ event_id = record_event_to_sqlite(
1180
1217
  db=db,
1181
1218
  session_id=active_session_id,
1182
1219
  tool_name=tool_name,
@@ -1191,6 +1228,20 @@ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
1191
1228
  feature_id=result.feature_id if result else None,
1192
1229
  )
1193
1230
 
1231
+ # Update presence
1232
+ presence_mgr = get_presence_manager()
1233
+ if presence_mgr and event_id:
1234
+ presence_mgr.update_presence(
1235
+ agent_id=detected_agent,
1236
+ event={
1237
+ "tool_name": tool_name,
1238
+ "session_id": active_session_id,
1239
+ "feature_id": result.feature_id if result else None,
1240
+ "cost_tokens": 0, # TODO: Extract from tool_response
1241
+ "event_id": event_id,
1242
+ },
1243
+ )
1244
+
1194
1245
  # If this was a Task() delegation, also record to agent_collaboration
1195
1246
  if tool_name == "Task" and db:
1196
1247
  subagent = tool_input_data.get("subagent_type", "general-purpose")
htmlgraph/models.py CHANGED
@@ -964,6 +964,7 @@ class Session(BaseModel):
964
964
  last_activity: datetime = Field(default_factory=datetime.now)
965
965
 
966
966
  start_commit: str | None = None # Git commit hash at session start
967
+ end_commit: str | None = None # Git commit hash at session end
967
968
  event_count: int = 0
968
969
 
969
970
  # Relationships
@@ -0,0 +1,148 @@
1
+ """
2
+ Reactive Query Integration - Connect to Event Pipeline
3
+
4
+ Integrates reactive queries with the broadcast system to automatically
5
+ invalidate queries when resources (features, events, sessions) are updated.
6
+
7
+ This module provides the glue between:
8
+ - Broadcast events (resource updates)
9
+ - Reactive query manager (query invalidation)
10
+ - Event tracker (tool call tracking)
11
+
12
+ When a resource is updated:
13
+ 1. Broadcast event is fired
14
+ 2. This module catches it
15
+ 3. Reactive query manager invalidates dependent queries
16
+ 4. Query subscribers receive new results via WebSocket
17
+ """
18
+
19
+ import logging
20
+ from typing import Any
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Global reactive query manager instance
25
+ _reactive_query_manager: Any = None
26
+
27
+
28
+ def set_reactive_query_manager(manager: Any) -> None:
29
+ """
30
+ Set global reactive query manager instance.
31
+
32
+ Args:
33
+ manager: ReactiveQueryManager instance
34
+ """
35
+ global _reactive_query_manager
36
+ _reactive_query_manager = manager
37
+ logger.info("Reactive query manager registered for event integration")
38
+
39
+
40
+ def get_reactive_query_manager() -> Any | None:
41
+ """
42
+ Get global reactive query manager instance.
43
+
44
+ Returns:
45
+ ReactiveQueryManager instance or None if not initialized
46
+ """
47
+ return _reactive_query_manager
48
+
49
+
50
+ async def on_broadcast_event(event: dict[str, Any]) -> None:
51
+ """
52
+ Called when a broadcast event occurs.
53
+
54
+ Invalidates queries that depend on the updated resource.
55
+
56
+ Args:
57
+ event: Broadcast event data
58
+ """
59
+ if not _reactive_query_manager:
60
+ return
61
+
62
+ resource_id = event.get("resource_id")
63
+ resource_type = event.get("resource_type")
64
+
65
+ if resource_id and resource_type:
66
+ try:
67
+ await _reactive_query_manager.on_resource_updated(
68
+ resource_id, resource_type
69
+ )
70
+ logger.debug(
71
+ f"Reactive queries invalidated for {resource_type}:{resource_id}"
72
+ )
73
+ except Exception as e:
74
+ logger.error(f"Error invalidating queries for {resource_id}: {e}")
75
+
76
+
77
+ async def on_tool_call_complete(
78
+ session_id: str,
79
+ tool_name: str,
80
+ feature_id: str | None = None,
81
+ ) -> None:
82
+ """
83
+ Called when a tool call completes.
84
+
85
+ Invalidates queries that depend on the affected resources.
86
+
87
+ Args:
88
+ session_id: Session ID
89
+ tool_name: Tool that was called
90
+ feature_id: Feature ID if applicable
91
+ """
92
+ if not _reactive_query_manager:
93
+ return
94
+
95
+ try:
96
+ # Tool calls affect events
97
+ await _reactive_query_manager.on_resource_updated(session_id, "event")
98
+
99
+ # If feature involved, invalidate feature queries
100
+ if feature_id:
101
+ await _reactive_query_manager.on_resource_updated(feature_id, "feature")
102
+
103
+ logger.debug(
104
+ f"Reactive queries invalidated for tool call: {tool_name} "
105
+ f"(session={session_id}, feature={feature_id})"
106
+ )
107
+
108
+ except Exception as e:
109
+ logger.error(f"Error invalidating queries for tool call: {e}")
110
+
111
+
112
+ async def on_session_start(session_id: str, agent_id: str) -> None:
113
+ """
114
+ Called when a session starts.
115
+
116
+ Invalidates queries that depend on sessions.
117
+
118
+ Args:
119
+ session_id: Session ID
120
+ agent_id: Agent ID
121
+ """
122
+ if not _reactive_query_manager:
123
+ return
124
+
125
+ try:
126
+ await _reactive_query_manager.on_resource_updated(session_id, "session")
127
+ logger.debug(f"Reactive queries invalidated for session start: {session_id}")
128
+ except Exception as e:
129
+ logger.error(f"Error invalidating queries for session start: {e}")
130
+
131
+
132
+ async def on_session_end(session_id: str) -> None:
133
+ """
134
+ Called when a session ends.
135
+
136
+ Invalidates queries that depend on sessions.
137
+
138
+ Args:
139
+ session_id: Session ID
140
+ """
141
+ if not _reactive_query_manager:
142
+ return
143
+
144
+ try:
145
+ await _reactive_query_manager.on_resource_updated(session_id, "session")
146
+ logger.debug(f"Reactive queries invalidated for session end: {session_id}")
147
+ except Exception as e:
148
+ logger.error(f"Error invalidating queries for session end: {e}")
@@ -713,6 +713,7 @@ class SessionManager:
713
713
  handoff_notes: str | None = None,
714
714
  recommended_next: str | None = None,
715
715
  blockers: list[str] | None = None,
716
+ end_commit: str | None = None,
716
717
  ) -> Session | None:
717
718
  """
718
719
  End a session.
@@ -722,6 +723,7 @@ class SessionManager:
722
723
  handoff_notes: Optional handoff notes for next session
723
724
  recommended_next: Optional recommended next steps
724
725
  blockers: Optional list of blockers
726
+ end_commit: Optional git commit hash at session end
725
727
 
726
728
  Returns:
727
729
  Updated Session or None if not found
@@ -736,6 +738,11 @@ class SessionManager:
736
738
  session.recommended_next = recommended_next
737
739
  if blockers is not None:
738
740
  session.blockers = blockers
741
+ if end_commit is not None:
742
+ session.end_commit = end_commit
743
+ elif not session.end_commit:
744
+ # Auto-detect current commit if not provided
745
+ session.end_commit = self._get_current_commit()
739
746
 
740
747
  session.end()
741
748
  session.add_activity(
@@ -0,0 +1,21 @@
1
+ """
2
+ Git-based synchronization for multi-device continuity.
3
+
4
+ Enables automatic sync of .htmlgraph/ directory across devices via Git.
5
+ """
6
+
7
+ from .git_sync import (
8
+ GitSyncManager,
9
+ SyncConfig,
10
+ SyncResult,
11
+ SyncStatus,
12
+ SyncStrategy,
13
+ )
14
+
15
+ __all__ = [
16
+ "GitSyncManager",
17
+ "SyncConfig",
18
+ "SyncStrategy",
19
+ "SyncStatus",
20
+ "SyncResult",
21
+ ]