htmlgraph 0.26.5__py3-none-any.whl → 0.26.6__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 (69) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +1 -1
  2. htmlgraph/__init__.py +1 -1
  3. htmlgraph/api/main.py +50 -10
  4. htmlgraph/api/templates/dashboard-redesign.html +608 -54
  5. htmlgraph/api/templates/partials/activity-feed.html +21 -0
  6. htmlgraph/api/templates/partials/features.html +81 -12
  7. htmlgraph/api/templates/partials/orchestration.html +35 -0
  8. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  9. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  10. htmlgraph/cli/__init__.py +42 -0
  11. htmlgraph/cli/__main__.py +6 -0
  12. htmlgraph/cli/analytics.py +939 -0
  13. htmlgraph/cli/base.py +660 -0
  14. htmlgraph/cli/constants.py +206 -0
  15. htmlgraph/cli/core.py +856 -0
  16. htmlgraph/cli/main.py +143 -0
  17. htmlgraph/cli/models.py +462 -0
  18. htmlgraph/cli/templates/__init__.py +1 -0
  19. htmlgraph/cli/templates/cost_dashboard.py +398 -0
  20. htmlgraph/cli/work/__init__.py +159 -0
  21. htmlgraph/cli/work/features.py +567 -0
  22. htmlgraph/cli/work/orchestration.py +675 -0
  23. htmlgraph/cli/work/sessions.py +465 -0
  24. htmlgraph/cli/work/tracks.py +485 -0
  25. htmlgraph/dashboard.html +6414 -634
  26. htmlgraph/db/schema.py +8 -3
  27. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +20 -13
  28. htmlgraph/docs/README.md +2 -3
  29. htmlgraph/hooks/event_tracker.py +157 -25
  30. htmlgraph/hooks/git_commands.py +175 -0
  31. htmlgraph/hooks/orchestrator.py +137 -71
  32. htmlgraph/hooks/orchestrator_reflector.py +23 -0
  33. htmlgraph/hooks/pretooluse.py +29 -6
  34. htmlgraph/hooks/session_handler.py +28 -0
  35. htmlgraph/hooks/session_summary.py +391 -0
  36. htmlgraph/hooks/subagent_detection.py +202 -0
  37. htmlgraph/hooks/validator.py +192 -79
  38. htmlgraph/operations/__init__.py +18 -0
  39. htmlgraph/operations/initialization.py +596 -0
  40. htmlgraph/operations/initialization.py.backup +228 -0
  41. htmlgraph/orchestration/__init__.py +16 -1
  42. htmlgraph/orchestration/claude_launcher.py +185 -0
  43. htmlgraph/orchestration/command_builder.py +71 -0
  44. htmlgraph/orchestration/headless_spawner.py +72 -1332
  45. htmlgraph/orchestration/plugin_manager.py +136 -0
  46. htmlgraph/orchestration/prompts.py +137 -0
  47. htmlgraph/orchestration/spawners/__init__.py +16 -0
  48. htmlgraph/orchestration/spawners/base.py +194 -0
  49. htmlgraph/orchestration/spawners/claude.py +170 -0
  50. htmlgraph/orchestration/spawners/codex.py +442 -0
  51. htmlgraph/orchestration/spawners/copilot.py +299 -0
  52. htmlgraph/orchestration/spawners/gemini.py +478 -0
  53. htmlgraph/orchestration/subprocess_runner.py +33 -0
  54. htmlgraph/orchestration.md +563 -0
  55. htmlgraph/orchestrator-system-prompt-optimized.txt +620 -55
  56. htmlgraph/orchestrator_config.py +357 -0
  57. htmlgraph/orchestrator_mode.py +45 -12
  58. htmlgraph/transcript.py +16 -4
  59. htmlgraph-0.26.6.data/data/htmlgraph/dashboard.html +6592 -0
  60. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.6.dist-info}/METADATA +1 -1
  61. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.6.dist-info}/RECORD +67 -33
  62. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.6.dist-info}/entry_points.txt +1 -1
  63. htmlgraph/cli.py +0 -7256
  64. htmlgraph-0.26.5.data/data/htmlgraph/dashboard.html +0 -812
  65. {htmlgraph-0.26.5.data → htmlgraph-0.26.6.data}/data/htmlgraph/styles.css +0 -0
  66. {htmlgraph-0.26.5.data → htmlgraph-0.26.6.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  67. {htmlgraph-0.26.5.data → htmlgraph-0.26.6.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  68. {htmlgraph-0.26.5.data → htmlgraph-0.26.6.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  69. {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.6.dist-info}/WHEEL +0 -0
@@ -10,6 +10,8 @@ Architecture:
10
10
  - Classifies operations into ALLOWED vs BLOCKED categories
11
11
  - Tracks tool usage sequences to detect exploration patterns
12
12
  - Provides clear Task delegation suggestions when blocking
13
+ - Subagents spawned via Task() have unrestricted tool access
14
+ - Detection uses 5-level strategy: env vars, session state, database
13
15
 
14
16
  Operation Categories:
15
17
  1. ALWAYS ALLOWED - Task, AskUserQuestion, TodoWrite, SDK operations
@@ -27,74 +29,73 @@ Public API:
27
29
 
28
30
  import json
29
31
  import re
30
- from datetime import datetime, timezone
31
32
  from pathlib import Path
32
- from typing import Any, cast
33
+ from typing import Any
33
34
 
35
+ from htmlgraph.hooks.subagent_detection import is_subagent_context
36
+ from htmlgraph.orchestrator_config import load_orchestrator_config
34
37
  from htmlgraph.orchestrator_mode import OrchestratorModeManager
35
38
  from htmlgraph.orchestrator_validator import OrchestratorValidator
36
39
 
37
- # Tool history file (temporary storage for session)
38
- TOOL_HISTORY_FILE = Path("/tmp/htmlgraph-tool-history.json")
40
+ # Maximum number of recent tool calls to consider for pattern detection
39
41
  MAX_HISTORY_SIZE = 50 # Keep last 50 tool calls
40
42
 
41
43
 
42
- def load_tool_history() -> list[dict]:
44
+ def load_tool_history(session_id: str) -> list[dict]:
43
45
  """
44
- Load recent tool history from temp file.
46
+ Load recent tool history from database (session-isolated).
47
+
48
+ Args:
49
+ session_id: Session identifier to filter tool history
45
50
 
46
51
  Returns:
47
52
  List of recent tool calls with tool name and timestamp
48
53
  """
49
- if not TOOL_HISTORY_FILE.exists():
50
- return []
51
-
52
54
  try:
53
- data = json.loads(TOOL_HISTORY_FILE.read_text())
54
- # Handle both formats: {"history": [...]} and [...] (legacy)
55
- if isinstance(data, list):
56
- return cast(list[dict[Any, Any]], data)
57
- return cast(list[dict[Any, Any]], data.get("history", []))
58
- except Exception:
59
- return []
60
-
55
+ from htmlgraph.db.schema import HtmlGraphDB
61
56
 
62
- def save_tool_history(history: list[dict]) -> None:
63
- """
64
- Save tool history to temp file.
57
+ # Find database path
58
+ cwd = Path.cwd()
59
+ graph_dir = cwd / ".htmlgraph"
60
+ if not graph_dir.exists():
61
+ for parent in [cwd.parent, cwd.parent.parent, cwd.parent.parent.parent]:
62
+ candidate = parent / ".htmlgraph"
63
+ if candidate.exists():
64
+ graph_dir = candidate
65
+ break
65
66
 
66
- Args:
67
- history: List of tool calls to persist
68
- """
69
- try:
70
- # Keep only recent history
71
- recent = (
72
- history[-MAX_HISTORY_SIZE:] if len(history) > MAX_HISTORY_SIZE else history
67
+ db_path = graph_dir / "htmlgraph.db"
68
+ if not db_path.exists():
69
+ return []
70
+
71
+ db = HtmlGraphDB(str(db_path))
72
+ if db.connection is None:
73
+ return []
74
+
75
+ cursor = db.connection.cursor()
76
+ cursor.execute(
77
+ """
78
+ SELECT tool_name, timestamp
79
+ FROM agent_events
80
+ WHERE session_id = ?
81
+ ORDER BY timestamp DESC
82
+ LIMIT ?
83
+ """,
84
+ (session_id, MAX_HISTORY_SIZE),
73
85
  )
74
- TOOL_HISTORY_FILE.write_text(json.dumps({"history": recent}, indent=2))
75
- except Exception:
76
- pass # Fail silently on history save errors
77
86
 
87
+ # Return in chronological order (oldest first) for pattern detection
88
+ rows = cursor.fetchall()
89
+ db.disconnect()
78
90
 
79
- def add_to_tool_history(tool: str) -> None:
80
- """
81
- Add a tool call to history.
82
-
83
- Args:
84
- tool: Name of the tool being called
85
- """
86
- history = load_tool_history()
87
- history.append(
88
- {
89
- "tool": tool,
90
- "timestamp": datetime.now(timezone.utc).isoformat(),
91
- }
92
- )
93
- save_tool_history(history)
91
+ return [{"tool": row[0], "timestamp": row[1]} for row in reversed(rows)]
92
+ except Exception:
93
+ # Graceful degradation - return empty history on error
94
+ return []
94
95
 
95
96
 
96
97
  def is_allowed_orchestrator_operation(
97
- tool: str, params: dict[str, Any]
98
+ tool: str, params: dict[str, Any], session_id: str = "unknown"
98
99
  ) -> tuple[bool, str, str]:
99
100
  """
100
101
  Check if operation is allowed for orchestrators.
@@ -102,6 +103,7 @@ def is_allowed_orchestrator_operation(
102
103
  Args:
103
104
  tool: Tool name (e.g., "Read", "Edit", "Bash")
104
105
  params: Tool parameters dict
106
+ session_id: Session identifier for loading tool history
105
107
 
106
108
  Returns:
107
109
  Tuple of (is_allowed, reason_if_not, category)
@@ -152,13 +154,12 @@ def is_allowed_orchestrator_operation(
152
154
  if command.startswith("uv run htmlgraph ") or command.startswith("htmlgraph "):
153
155
  return True, "", "sdk-command"
154
156
 
155
- # Allow git read-only commands
156
- if (
157
- command.startswith("git status")
158
- or command.startswith("git diff")
159
- or command.startswith("git log")
160
- ):
161
- return True, "", "git-readonly"
157
+ # Allow git read-only commands using shared classification
158
+ if command.strip().startswith("git"):
159
+ from htmlgraph.hooks.git_commands import should_allow_git_command
160
+
161
+ if should_allow_git_command(command):
162
+ return True, "", "git-readonly"
162
163
 
163
164
  # Allow SDK inline usage (Python inline with htmlgraph import)
164
165
  if "from htmlgraph import" in command or "import htmlgraph" in command:
@@ -195,16 +196,21 @@ def is_allowed_orchestrator_operation(
195
196
  # Category 3: Quick Lookups - Single operations only
196
197
  if tool in ["Read", "Grep", "Glob"]:
197
198
  # Check tool history to see if this is a single lookup or part of a sequence
198
- history = load_tool_history()
199
+ history = load_tool_history(session_id)
199
200
 
200
- # FIX #4: Check for mixed exploration pattern
201
+ # FIX #4: Check for mixed exploration pattern (configurable threshold)
202
+ config = load_orchestrator_config()
203
+ exploration_threshold = config.thresholds.exploration_calls
204
+
205
+ # Check last N calls (where N = threshold + 2)
206
+ lookback = min(exploration_threshold + 2, len(history))
201
207
  exploration_count = sum(
202
- 1 for h in history[-5:] if h["tool"] in ["Read", "Grep", "Glob"]
208
+ 1 for h in history[-lookback:] if h["tool"] in ["Read", "Grep", "Glob"]
203
209
  )
204
- if exploration_count >= 3 and enforcement_level == "strict":
210
+ if exploration_count >= exploration_threshold and enforcement_level == "strict":
205
211
  return (
206
212
  False,
207
- "Multiple exploration calls detected. Delegate to Explorer agent.\n\n"
213
+ f"Multiple exploration calls detected ({exploration_count}/{exploration_threshold}). Delegate to Explorer agent.\n\n"
208
214
  "Use Task tool with explorer subagent.",
209
215
  "exploration-blocked",
210
216
  )
@@ -372,21 +378,36 @@ def create_task_suggestion(tool: str, params: dict[str, Any]) -> str:
372
378
  )
373
379
 
374
380
 
375
- def enforce_orchestrator_mode(tool: str, params: dict[str, Any]) -> dict[str, Any]:
381
+ def enforce_orchestrator_mode(
382
+ tool: str, params: dict[str, Any], session_id: str = "unknown"
383
+ ) -> dict[str, Any]:
376
384
  """
377
385
  Enforce orchestrator mode rules.
378
386
 
379
387
  This is the main public API for hook scripts. It checks if orchestrator mode
380
388
  is enabled, classifies the operation, and returns a hook response dict.
381
389
 
390
+ Subagents spawned via Task() have unrestricted tool access.
391
+ Detection uses 5-level strategy: env vars, session state, database.
392
+
382
393
  Args:
383
394
  tool: Tool being called
384
395
  params: Tool parameters
396
+ session_id: Session identifier for loading tool history
385
397
 
386
398
  Returns:
387
399
  Hook response dict with decision (allow/block) and guidance
388
400
  Format: {"continue": bool, "hookSpecificOutput": {...}}
389
401
  """
402
+ # Check if this is a subagent context - subagents have unrestricted tool access
403
+ if is_subagent_context():
404
+ return {
405
+ "hookSpecificOutput": {
406
+ "hookEventName": "PreToolUse",
407
+ "permissionDecision": "allow",
408
+ },
409
+ }
410
+
390
411
  # Get manager and check if mode is enabled
391
412
  try:
392
413
  # Look for .htmlgraph directory starting from cwd
@@ -405,7 +426,6 @@ def enforce_orchestrator_mode(tool: str, params: dict[str, Any]) -> dict[str, An
405
426
 
406
427
  if not manager.is_enabled():
407
428
  # Mode not active, allow everything
408
- add_to_tool_history(tool)
409
429
  return {
410
430
  "hookSpecificOutput": {
411
431
  "hookEventName": "PreToolUse",
@@ -416,7 +436,6 @@ def enforce_orchestrator_mode(tool: str, params: dict[str, Any]) -> dict[str, An
416
436
  enforcement_level = manager.get_enforcement_level()
417
437
  except Exception:
418
438
  # If we can't check mode, fail open (allow)
419
- add_to_tool_history(tool)
420
439
  return {
421
440
  "hookSpecificOutput": {
422
441
  "hookEventName": "PreToolUse",
@@ -424,20 +443,26 @@ def enforce_orchestrator_mode(tool: str, params: dict[str, Any]) -> dict[str, An
424
443
  },
425
444
  }
426
445
 
427
- # Check if circuit breaker is triggered in strict mode
446
+ # Check if circuit breaker is triggered in strict mode (configurable threshold)
447
+ config = load_orchestrator_config()
448
+ circuit_breaker_threshold = config.thresholds.circuit_breaker_violations
449
+
428
450
  if enforcement_level == "strict" and manager.is_circuit_breaker_triggered():
429
451
  # Circuit breaker triggered - block all non-core operations
430
452
  if tool not in ["Task", "AskUserQuestion", "TodoWrite"]:
453
+ violation_count = manager.get_violation_count()
431
454
  circuit_breaker_message = (
432
455
  "🚨 ORCHESTRATOR CIRCUIT BREAKER TRIGGERED\n\n"
433
- f"You have violated delegation rules {manager.get_violation_count()} times this session.\n\n"
456
+ f"You have violated delegation rules {violation_count} times this session "
457
+ f"(threshold: {circuit_breaker_threshold}).\n\n"
434
458
  "Violations detected:\n"
435
459
  "- Direct execution instead of delegation\n"
436
460
  "- Context waste on tactical operations\n\n"
437
461
  "Options:\n"
438
462
  "1. Disable orchestrator mode: uv run htmlgraph orchestrator disable\n"
439
463
  "2. Change to guidance mode: uv run htmlgraph orchestrator set-level guidance\n"
440
- "3. Reset counter (acknowledge violations): uv run htmlgraph orchestrator reset-violations\n\n"
464
+ "3. Reset counter (acknowledge violations): uv run htmlgraph orchestrator reset-violations\n"
465
+ "4. Adjust thresholds: uv run htmlgraph orchestrator config set thresholds.circuit_breaker_violations <N>\n\n"
441
466
  "To proceed, choose an option above."
442
467
  )
443
468
 
@@ -449,11 +474,13 @@ def enforce_orchestrator_mode(tool: str, params: dict[str, Any]) -> dict[str, An
449
474
  },
450
475
  }
451
476
 
452
- # Check if operation is allowed
453
- is_allowed, reason, category = is_allowed_orchestrator_operation(tool, params)
477
+ # Check if operation is allowed (pass session_id for history lookup)
478
+ is_allowed, reason, category = is_allowed_orchestrator_operation(
479
+ tool, params, session_id
480
+ )
454
481
 
455
- # Add to history (for sequence detection)
456
- add_to_tool_history(tool)
482
+ # Note: Tool recording is now handled by track-event.py PostToolUse hook
483
+ # No need to call add_to_tool_history() here
457
484
 
458
485
  # Operation is allowed
459
486
  if is_allowed:
@@ -488,19 +515,19 @@ def enforce_orchestrator_mode(tool: str, params: dict[str, Any]) -> dict[str, An
488
515
  if enforcement_level == "strict":
489
516
  # STRICT mode - loud warning with violation count
490
517
  error_message = (
491
- f"🚫 ORCHESTRATOR MODE VIOLATION ({violations}/3): {reason}\n\n"
518
+ f"🚫 ORCHESTRATOR MODE VIOLATION ({violations}/{circuit_breaker_threshold}): {reason}\n\n"
492
519
  f"⚠️ WARNING: Direct operations waste context and break delegation pattern!\n\n"
493
520
  f"Suggested delegation:\n"
494
521
  f"{suggestion}\n\n"
495
522
  )
496
523
 
497
524
  # Add circuit breaker warning if approaching threshold
498
- if violations >= 3:
525
+ if violations >= circuit_breaker_threshold:
499
526
  error_message += (
500
527
  "🚨 CIRCUIT BREAKER TRIGGERED - Further violations will be blocked!\n\n"
501
528
  "Reset with: uv run htmlgraph orchestrator reset-violations\n"
502
529
  )
503
- elif violations == 2:
530
+ elif violations == circuit_breaker_threshold - 1:
504
531
  error_message += "⚠️ Next violation will trigger circuit breaker!\n\n"
505
532
 
506
533
  error_message += (
@@ -528,3 +555,42 @@ def enforce_orchestrator_mode(tool: str, params: dict[str, Any]) -> dict[str, An
528
555
  "additionalContext": warning_message,
529
556
  },
530
557
  }
558
+
559
+
560
+ def main() -> None:
561
+ """Hook entry point for script wrapper."""
562
+ import os
563
+ import sys
564
+
565
+ # Check if tracking is disabled
566
+ if os.environ.get("HTMLGRAPH_DISABLE_TRACKING") == "1":
567
+ print(json.dumps({"continue": True}))
568
+ sys.exit(0)
569
+
570
+ # Check for orchestrator mode environment override
571
+ if os.environ.get("HTMLGRAPH_ORCHESTRATOR_DISABLED") == "1":
572
+ print(json.dumps({"continue": True}))
573
+ sys.exit(0)
574
+
575
+ try:
576
+ hook_input = json.load(sys.stdin)
577
+ except json.JSONDecodeError:
578
+ hook_input = {}
579
+
580
+ # Get tool name and parameters (Claude Code uses "name" and "input")
581
+ tool_name = hook_input.get("name", "") or hook_input.get("tool_name", "")
582
+ tool_input = hook_input.get("input", {}) or hook_input.get("tool_input", {})
583
+
584
+ # Get session_id from hook_input (NEW: required for session-isolated history)
585
+ session_id = hook_input.get("session_id", "unknown")
586
+
587
+ if not tool_name:
588
+ # No tool name, allow
589
+ print(json.dumps({"continue": True}))
590
+ return
591
+
592
+ # Enforce orchestrator mode with session_id for history lookup
593
+ response = enforce_orchestrator_mode(tool_name, tool_input, session_id)
594
+
595
+ # Output JSON response
596
+ print(json.dumps(response))
@@ -194,3 +194,26 @@ def orchestrator_reflect(tool_input: dict[str, Any]) -> dict[str, Any]:
194
194
  }
195
195
 
196
196
  return response
197
+
198
+
199
+ def main() -> None:
200
+ """Hook entry point for script wrapper."""
201
+ import json
202
+ import os
203
+ import sys
204
+
205
+ # Check if tracking is disabled
206
+ if os.environ.get("HTMLGRAPH_DISABLE_TRACKING") == "1":
207
+ print(json.dumps({"continue": True}))
208
+ sys.exit(0)
209
+
210
+ try:
211
+ hook_input = json.load(sys.stdin)
212
+ except json.JSONDecodeError:
213
+ hook_input = {}
214
+
215
+ # Run reflection logic
216
+ response = orchestrator_reflect(hook_input)
217
+
218
+ # Output JSON response
219
+ print(json.dumps(response))
@@ -363,7 +363,16 @@ def create_start_event(
363
363
 
364
364
  cursor = db.connection.cursor() # type: ignore[union-attr]
365
365
 
366
- # Get UserQuery event ID for ALL tool calls (links conversation turns)
366
+ # Determine parent event ID with proper hierarchy:
367
+ # 1. FIRST check HTMLGRAPH_PARENT_EVENT env var (set by Task delegation for subagents)
368
+ # 2. For Task() tool, create a new task_delegation event
369
+ # 3. Fall back to UserQuery only if no parent context available
370
+ #
371
+ # This ensures tool events executed within Task() subagents are properly
372
+ # nested under the Task delegation event, not flattened to UserQuery.
373
+ env_parent_event = os.environ.get("HTMLGRAPH_PARENT_EVENT")
374
+
375
+ # Get UserQuery event ID as fallback (for top-level tool calls)
367
376
  user_query_event_id = None
368
377
  try:
369
378
  from htmlgraph.hooks.event_tracker import get_parent_user_query
@@ -384,10 +393,21 @@ def create_start_event(
384
393
 
385
394
  event_id = f"evt-{str(uuid.uuid4())[:8]}"
386
395
 
387
- # Determine parent: Task() uses task_parent_event, others use UserQuery
388
- parent_event_id = (
389
- task_parent_event_id if tool_name == "Task" else user_query_event_id
390
- )
396
+ # Determine parent with proper hierarchy:
397
+ # - Task() tools: Use the newly created task_delegation event
398
+ # - Tools in subagent context: Use HTMLGRAPH_PARENT_EVENT (Task delegation)
399
+ # - Top-level tools: Fall back to UserQuery
400
+ if tool_name == "Task":
401
+ parent_event_id = task_parent_event_id
402
+ elif env_parent_event:
403
+ # Subagent context: tools should be children of Task delegation
404
+ parent_event_id = env_parent_event
405
+ logger.debug(
406
+ f"Using parent from environment: {env_parent_event} for {tool_name}"
407
+ )
408
+ else:
409
+ # Top-level context: tools are children of UserQuery
410
+ parent_event_id = user_query_event_id
391
411
 
392
412
  cursor.execute(
393
413
  """
@@ -557,10 +577,13 @@ async def run_validation_check(tool_input: dict[str, Any]) -> dict[str, Any]:
557
577
 
558
578
  tool_name = tool_input.get("name", "") or tool_input.get("tool", "")
559
579
  tool_params = tool_input.get("input", {}) or tool_input.get("params", {})
580
+ session_id = tool_input.get("session_id", "unknown")
560
581
 
561
582
  # Load config and history in thread pool
562
583
  config = await loop.run_in_executor(None, load_validation_config)
563
- history = await loop.run_in_executor(None, validator_load_history)
584
+ history = await loop.run_in_executor(
585
+ None, lambda: validator_load_history(session_id)
586
+ )
564
587
 
565
588
  # Run validation
566
589
  return await loop.run_in_executor(
@@ -635,3 +635,31 @@ __all__ = [
635
635
  "record_user_query_event",
636
636
  "check_version_status",
637
637
  ]
638
+
639
+
640
+ def main() -> None:
641
+ """Hook entry point for SessionEnd hook."""
642
+ import json
643
+ import os
644
+ import sys
645
+
646
+ # Check if tracking is disabled
647
+ if os.environ.get("HTMLGRAPH_DISABLE_TRACKING") == "1":
648
+ print(json.dumps({"continue": True}))
649
+ sys.exit(0)
650
+
651
+ try:
652
+ hook_input = json.load(sys.stdin)
653
+ except json.JSONDecodeError:
654
+ hook_input = {}
655
+
656
+ # Create context from hook input
657
+ from htmlgraph.hooks.context import HookContext
658
+
659
+ context = HookContext.from_input(hook_input)
660
+
661
+ # Handle session end
662
+ response = handle_session_end(context)
663
+
664
+ # Output JSON response
665
+ print(json.dumps(response))