htmlgraph 0.25.0__py3-none-any.whl → 0.26.2__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 (41) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
  2. htmlgraph/.htmlgraph/agents.json +72 -0
  3. htmlgraph/.htmlgraph/htmlgraph.db +0 -0
  4. htmlgraph/__init__.py +1 -1
  5. htmlgraph/api/main.py +252 -47
  6. htmlgraph/api/templates/dashboard.html +11 -0
  7. htmlgraph/api/templates/partials/activity-feed.html +517 -8
  8. htmlgraph/cli.py +1 -1
  9. htmlgraph/config.py +173 -96
  10. htmlgraph/dashboard.html +632 -7237
  11. htmlgraph/db/schema.py +258 -9
  12. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  13. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  14. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  15. htmlgraph/hooks/cigs_pretool_enforcer.py +2 -2
  16. htmlgraph/hooks/concurrent_sessions.py +208 -0
  17. htmlgraph/hooks/context.py +88 -10
  18. htmlgraph/hooks/drift_handler.py +24 -20
  19. htmlgraph/hooks/event_tracker.py +264 -189
  20. htmlgraph/hooks/orchestrator.py +6 -4
  21. htmlgraph/hooks/orchestrator_reflector.py +4 -4
  22. htmlgraph/hooks/pretooluse.py +63 -36
  23. htmlgraph/hooks/prompt_analyzer.py +14 -25
  24. htmlgraph/hooks/session_handler.py +123 -69
  25. htmlgraph/hooks/state_manager.py +7 -4
  26. htmlgraph/hooks/subagent_stop.py +3 -2
  27. htmlgraph/hooks/validator.py +15 -11
  28. htmlgraph/operations/fastapi_server.py +2 -2
  29. htmlgraph/orchestration/headless_spawner.py +489 -16
  30. htmlgraph/orchestration/live_events.py +377 -0
  31. htmlgraph/server.py +100 -203
  32. htmlgraph-0.26.2.data/data/htmlgraph/dashboard.html +812 -0
  33. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/METADATA +1 -1
  34. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/RECORD +40 -32
  35. htmlgraph-0.25.0.data/data/htmlgraph/dashboard.html +0 -7417
  36. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/styles.css +0 -0
  37. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  38. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  39. {htmlgraph-0.25.0.data → htmlgraph-0.26.2.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  40. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/WHEEL +0 -0
  41. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.2.dist-info}/entry_points.txt +0 -0
@@ -5,12 +5,17 @@ Reusable event tracking logic for hook integrations.
5
5
  Provides session management, drift detection, activity logging, and SQLite persistence.
6
6
 
7
7
  Public API:
8
- track_event(hook_type: str, tool_input: dict) -> dict
8
+ track_event(hook_type: str, tool_input: dict[str, Any]) -> dict
9
9
  Main entry point for tracking hook events (PostToolUse, Stop, UserPromptSubmit)
10
10
 
11
11
  Events are recorded to both:
12
12
  - HTML files via SessionManager (existing)
13
13
  - SQLite database via HtmlGraphDB (new - for dashboard queries)
14
+
15
+ Parent-child event linking:
16
+ - Database is the single source of truth for parent-child linking
17
+ - UserQuery events are stored in agent_events table with tool_name='UserQuery'
18
+ - get_parent_user_query() queries database for most recent UserQuery in session
14
19
  """
15
20
 
16
21
  import json
@@ -20,7 +25,7 @@ import subprocess
20
25
  import sys
21
26
  from datetime import datetime, timedelta, timezone
22
27
  from pathlib import Path
23
- from typing import Any, cast
28
+ from typing import Any, cast # noqa: F401
24
29
 
25
30
  from htmlgraph.db.schema import HtmlGraphDB
26
31
  from htmlgraph.ids import generate_id
@@ -28,18 +33,50 @@ from htmlgraph.session_manager import SessionManager
28
33
 
29
34
  # Drift classification queue (stored in session directory)
30
35
  DRIFT_QUEUE_FILE = "drift-queue.json"
31
- # Active parent activity tracker (for Skill/Task invocations)
32
- PARENT_ACTIVITY_FILE = "parent-activity.json"
33
- # UserQuery event tracker (for parent-child linking) - DEPRECATED (use session-scoped files)
34
- USER_QUERY_EVENT_FILE = "user-query-event.json"
35
36
 
36
37
 
37
- def get_user_query_event_file(graph_dir: Path, session_id: str) -> Path:
38
- """Get the session-scoped user query event file path."""
39
- return graph_dir / f"user-query-event-{session_id}.json"
38
+ def get_model_from_status_cache(session_id: str | None = None) -> str | None:
39
+ """
40
+ Read current model from SQLite model_cache table.
41
+
42
+ The status line script writes model info to the model_cache table.
43
+ This allows hooks to know which Claude model is currently running,
44
+ even though hooks don't receive model info directly from Claude Code.
40
45
 
46
+ Args:
47
+ session_id: Unused, kept for backward compatibility.
48
+
49
+ Returns:
50
+ Model display name (e.g., "Opus 4.5", "Sonnet", "Haiku") or None if not found.
51
+ """
52
+ import sqlite3
41
53
 
42
- def load_drift_config() -> dict:
54
+ try:
55
+ # Try project database first
56
+ db_path = Path.cwd() / ".htmlgraph" / "htmlgraph.db"
57
+ if not db_path.exists():
58
+ return None
59
+
60
+ conn = sqlite3.connect(str(db_path), timeout=1.0)
61
+ cursor = conn.cursor()
62
+
63
+ # Check if model_cache table exists and has data
64
+ cursor.execute("SELECT model FROM model_cache WHERE id = 1 LIMIT 1")
65
+ row = cursor.fetchone()
66
+ conn.close()
67
+
68
+ if row and row[0] and row[0] != "Claude":
69
+ return str(row[0])
70
+ return str(row[0]) if row else None
71
+
72
+ except Exception:
73
+ # Table doesn't exist or read error - silently fail
74
+ pass
75
+
76
+ return None
77
+
78
+
79
+ def load_drift_config() -> dict[str, Any]:
43
80
  """Load drift configuration from plugin config or project .claude directory."""
44
81
  config_paths = [
45
82
  Path(__file__).parent.parent.parent.parent.parent
@@ -80,110 +117,46 @@ def load_drift_config() -> dict:
80
117
  }
81
118
 
82
119
 
83
- def load_parent_activity(graph_dir: Path) -> dict:
84
- """Load the active parent activity state."""
85
- path = graph_dir / PARENT_ACTIVITY_FILE
86
- if path.exists():
87
- try:
88
- with open(path) as f:
89
- data = cast(dict[Any, Any], json.load(f))
90
- # Clean up stale parent activities (older than 5 minutes)
91
- if data.get("timestamp"):
92
- ts = datetime.fromisoformat(data["timestamp"])
93
- # Use timezone-aware datetime for comparison
94
- now = datetime.now(timezone.utc)
95
- # Ensure ts is timezone-aware (handle both formats)
96
- if ts.tzinfo is None:
97
- ts = ts.replace(tzinfo=timezone.utc)
98
- if now - ts > timedelta(minutes=5):
99
- return {}
100
- return data
101
- except Exception:
102
- pass
103
- return {}
104
-
105
-
106
- def save_parent_activity(
107
- graph_dir: Path, parent_id: str | None, tool: str | None = None
108
- ) -> None:
109
- """Save the active parent activity state."""
110
- path = graph_dir / PARENT_ACTIVITY_FILE
111
- try:
112
- if parent_id:
113
- with open(path, "w") as f:
114
- json.dump(
115
- {
116
- "parent_id": parent_id,
117
- "tool": tool,
118
- "timestamp": datetime.now(timezone.utc).isoformat(),
119
- },
120
- f,
121
- )
122
- else:
123
- # Clear parent activity
124
- path.unlink(missing_ok=True)
125
- except Exception as e:
126
- print(f"Warning: Could not save parent activity: {e}", file=sys.stderr)
127
-
128
-
129
- def load_user_query_event(graph_dir: Path, session_id: str) -> str | None:
120
+ def get_parent_user_query(db: HtmlGraphDB, session_id: str) -> str | None:
130
121
  """
131
- Load the active UserQuery event ID for parent-child linking.
132
-
133
- Session-scoped: Each session maintains its own parent context via
134
- user-query-event-{SESSION_ID}.json to support multiple concurrent
135
- Claude windows in the same project.
136
- """
137
- path = get_user_query_event_file(graph_dir, session_id)
138
- if path.exists():
139
- try:
140
- with open(path) as f:
141
- data = cast(dict[Any, Any], json.load(f))
142
- # Clean up stale UserQuery events (older than 10 minutes)
143
- if data.get("timestamp"):
144
- ts = datetime.fromisoformat(data["timestamp"])
145
- now = datetime.now(timezone.utc)
146
- if ts.tzinfo is None:
147
- ts = ts.replace(tzinfo=timezone.utc)
148
- # UserQuery events expire after 10 minutes (conversation turn boundary)
149
- # This allows tool calls up to 10 minutes after a user query to be linked as children
150
- if now - ts > timedelta(minutes=10):
151
- return None
152
- return data.get("event_id")
153
- except Exception:
154
- pass
155
- return None
122
+ Get the most recent UserQuery event_id for this session from database.
156
123
 
124
+ This is the primary method for parent-child event linking.
125
+ Database is the single source of truth - no file-based state.
157
126
 
158
- def save_user_query_event(
159
- graph_dir: Path, session_id: str, event_id: str | None
160
- ) -> None:
161
- """
162
- Save the active UserQuery event ID for parent-child linking.
127
+ Args:
128
+ db: HtmlGraphDB instance
129
+ session_id: Session ID to query
163
130
 
164
- Session-scoped: Each session maintains its own parent context via
165
- user-query-event-{SESSION_ID}.json to support multiple concurrent
166
- Claude windows in the same project.
131
+ Returns:
132
+ event_id of the most recent UserQuery event, or None if not found
167
133
  """
168
- path = get_user_query_event_file(graph_dir, session_id)
169
134
  try:
170
- if event_id:
171
- with open(path, "w") as f:
172
- json.dump(
173
- {
174
- "event_id": event_id,
175
- "timestamp": datetime.now(timezone.utc).isoformat(),
176
- },
177
- f,
178
- )
179
- else:
180
- # Clear UserQuery event
181
- path.unlink(missing_ok=True)
135
+ if db.connection is None:
136
+ return None
137
+ cursor = db.connection.cursor()
138
+ cursor.execute(
139
+ """
140
+ SELECT event_id FROM agent_events
141
+ WHERE session_id = ? AND tool_name = 'UserQuery'
142
+ ORDER BY timestamp DESC
143
+ LIMIT 1
144
+ """,
145
+ (session_id,),
146
+ )
147
+ row = cursor.fetchone()
148
+ if row:
149
+ return str(row[0])
150
+ return None
182
151
  except Exception as e:
183
- print(f"Warning: Could not save UserQuery event: {e}", file=sys.stderr)
152
+ print(
153
+ f"Debug: Database query for UserQuery failed: {e}",
154
+ file=sys.stderr,
155
+ )
156
+ return None
184
157
 
185
158
 
186
- def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict:
159
+ def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict[str, Any]:
187
160
  """
188
161
  Load the drift queue from file and clean up stale entries.
189
162
 
@@ -232,7 +205,7 @@ def load_drift_queue(graph_dir: Path, max_age_hours: int = 48) -> dict:
232
205
  return {"activities": [], "last_classification": None}
233
206
 
234
207
 
235
- def save_drift_queue(graph_dir: Path, queue: dict) -> None:
208
+ def save_drift_queue(graph_dir: Path, queue: dict[str, Any]) -> None:
236
209
  """Save the drift queue to file."""
237
210
  queue_path = graph_dir / DRIFT_QUEUE_FILE
238
211
  try:
@@ -266,7 +239,9 @@ def clear_drift_queue_activities(graph_dir: Path) -> None:
266
239
  print(f"Warning: Could not clear drift queue: {e}", file=sys.stderr)
267
240
 
268
241
 
269
- def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
242
+ def add_to_drift_queue(
243
+ graph_dir: Path, activity: dict[str, Any], config: dict[str, Any]
244
+ ) -> dict[str, Any]:
270
245
  """Add a high-drift activity to the queue."""
271
246
  max_age_hours = config.get("queue", {}).get("max_age_hours", 48)
272
247
  queue = load_drift_queue(graph_dir, max_age_hours=max_age_hours)
@@ -289,7 +264,9 @@ def add_to_drift_queue(graph_dir: Path, activity: dict, config: dict) -> dict:
289
264
  return queue
290
265
 
291
266
 
292
- def should_trigger_classification(queue: dict, config: dict) -> bool:
267
+ def should_trigger_classification(
268
+ queue: dict[str, Any], config: dict[str, Any]
269
+ ) -> bool:
293
270
  """Check if we should trigger auto-classification."""
294
271
  drift_config = config.get("drift_detection", {})
295
272
 
@@ -316,7 +293,7 @@ def should_trigger_classification(queue: dict, config: dict) -> bool:
316
293
  return True
317
294
 
318
295
 
319
- def build_classification_prompt(queue: dict, feature_id: str) -> str:
296
+ def build_classification_prompt(queue: dict[str, Any], feature_id: str) -> str:
320
297
  """Build the prompt for the classification agent."""
321
298
  activities = queue.get("activities", [])
322
299
 
@@ -368,41 +345,108 @@ def resolve_project_path(cwd: str | None = None) -> str:
368
345
  return start_dir
369
346
 
370
347
 
371
- def detect_agent_from_environment() -> str:
348
+ def detect_model_from_hook_input(hook_input: dict[str, Any]) -> str | None:
349
+ """
350
+ Detect the Claude model from hook input data.
351
+
352
+ Checks in order of priority:
353
+ 1. Task() model parameter (if tool_name == 'Task')
354
+ 2. HTMLGRAPH_MODEL environment variable (set by hooks)
355
+ 3. ANTHROPIC_MODEL or CLAUDE_MODEL environment variables
356
+
357
+ Args:
358
+ hook_input: Hook input dict containing tool_name and tool_input
359
+
360
+ Returns:
361
+ Model name (e.g., 'claude-opus', 'claude-sonnet', 'claude-haiku') or None
362
+ """
363
+ # Get tool info
364
+ tool_name_value: Any = hook_input.get("tool_name", "") or hook_input.get("name", "")
365
+ tool_name = tool_name_value if isinstance(tool_name_value, str) else ""
366
+ tool_input_value: Any = hook_input.get("tool_input", {}) or hook_input.get(
367
+ "input", {}
368
+ )
369
+ tool_input = tool_input_value if isinstance(tool_input_value, dict) else {}
370
+
371
+ # 1. Check for Task() model parameter first
372
+ if tool_name == "Task" and "model" in tool_input:
373
+ model_value: Any = tool_input.get("model")
374
+ if model_value and isinstance(model_value, str):
375
+ model = model_value.strip().lower()
376
+ if model:
377
+ if not model.startswith("claude-"):
378
+ model = f"claude-{model}"
379
+ return cast(str, model)
380
+
381
+ # 2. Check environment variables (set by PreToolUse hook)
382
+ for env_var in ["HTMLGRAPH_MODEL", "ANTHROPIC_MODEL", "CLAUDE_MODEL"]:
383
+ value = os.environ.get(env_var)
384
+ if value and isinstance(value, str):
385
+ model = value.strip()
386
+ if model:
387
+ return model
388
+
389
+ return None
390
+
391
+
392
+ def detect_agent_from_environment() -> tuple[str, str | None]:
372
393
  """
373
- Detect the agent/model name from environment variables.
394
+ Detect the agent/model name from environment variables and status cache.
374
395
 
375
- Checks multiple environment variables in order of priority:
396
+ Checks multiple sources in order of priority:
376
397
  1. HTMLGRAPH_AGENT - Explicit agent name set by user
377
398
  2. HTMLGRAPH_SUBAGENT_TYPE - For subagent sessions
378
- 3. CLAUDE_MODEL - Model name if exposed by Claude Code
379
- 4. ANTHROPIC_MODEL - Alternative model env var
380
- 5. HTMLGRAPH_PARENT_AGENT - Parent agent context
399
+ 3. HTMLGRAPH_PARENT_AGENT - Parent agent context
400
+ 4. HTMLGRAPH_MODEL - Model name (e.g., claude-haiku, claude-opus)
401
+ 5. CLAUDE_MODEL - Model name if exposed by Claude Code
402
+ 6. ANTHROPIC_MODEL - Alternative model env var
403
+ 7. Status line cache (model only) - ~/.cache/claude-code/status-{session_id}.json
381
404
 
382
405
  Falls back to 'claude-code' if no environment variable is set.
383
406
 
384
407
  Returns:
385
- Agent/model identifier string
408
+ Tuple of (agent_id, model_name). Model name may be None if not detected.
386
409
  """
387
- # Check environment variables in priority order
388
- env_vars = [
410
+ # Check for explicit agent name first
411
+ agent_id = None
412
+ env_vars_agent = [
389
413
  "HTMLGRAPH_AGENT",
390
414
  "HTMLGRAPH_SUBAGENT_TYPE",
415
+ "HTMLGRAPH_PARENT_AGENT",
416
+ ]
417
+
418
+ for var in env_vars_agent:
419
+ value = os.environ.get(var)
420
+ if value and value.strip():
421
+ agent_id = value.strip()
422
+ break
423
+
424
+ # Check for model name separately
425
+ model_name = None
426
+ env_vars_model = [
427
+ "HTMLGRAPH_MODEL",
391
428
  "CLAUDE_MODEL",
392
429
  "ANTHROPIC_MODEL",
393
- "HTMLGRAPH_PARENT_AGENT",
394
430
  ]
395
431
 
396
- for var in env_vars:
432
+ for var in env_vars_model:
397
433
  value = os.environ.get(var)
398
434
  if value and value.strip():
399
- return value.strip()
435
+ model_name = value.strip()
436
+ break
437
+
438
+ # Fallback: Try to read model from status line cache
439
+ if not model_name:
440
+ model_name = get_model_from_status_cache()
441
+
442
+ # Default fallback for agent_id
443
+ if not agent_id:
444
+ agent_id = "claude-code"
400
445
 
401
- # Default fallback
402
- return "claude-code"
446
+ return agent_id, model_name
403
447
 
404
448
 
405
- def extract_file_paths(tool_input: dict, tool_name: str) -> list[str]:
449
+ def extract_file_paths(tool_input: dict[str, Any], tool_name: str) -> list[str]:
406
450
  """Extract file paths from tool input based on tool type."""
407
451
  paths = []
408
452
 
@@ -427,7 +471,7 @@ def extract_file_paths(tool_input: dict, tool_name: str) -> list[str]:
427
471
 
428
472
 
429
473
  def format_tool_summary(
430
- tool_name: str, tool_input: dict, tool_result: dict | None = None
474
+ tool_name: str, tool_input: dict[str, Any], tool_result: dict | None = None
431
475
  ) -> str:
432
476
  """Format a human-readable summary of the tool call."""
433
477
  if tool_name == "Read":
@@ -491,13 +535,14 @@ def record_event_to_sqlite(
491
535
  db: HtmlGraphDB,
492
536
  session_id: str,
493
537
  tool_name: str,
494
- tool_input: dict,
495
- tool_response: dict,
538
+ tool_input: dict[str, Any],
539
+ tool_response: dict[str, Any],
496
540
  is_error: bool,
497
541
  file_paths: list[str] | None = None,
498
542
  parent_event_id: str | None = None,
499
543
  agent_id: str | None = None,
500
544
  subagent_type: str | None = None,
545
+ model: str | None = None,
501
546
  ) -> str | None:
502
547
  """
503
548
  Record a tool call event to SQLite database for dashboard queries.
@@ -513,6 +558,7 @@ def record_event_to_sqlite(
513
558
  parent_event_id: Parent event ID if this is a child event
514
559
  agent_id: Agent identifier (optional)
515
560
  subagent_type: Subagent type for Task delegations (optional)
561
+ model: Claude model name (e.g., claude-haiku, claude-opus) (optional)
516
562
 
517
563
  Returns:
518
564
  event_id if successful, None otherwise
@@ -523,7 +569,7 @@ def record_event_to_sqlite(
523
569
 
524
570
  # Build output summary from tool response
525
571
  output_summary = ""
526
- if isinstance(tool_response, dict):
572
+ if isinstance(tool_response, dict): # type: ignore[arg-type]
527
573
  if is_error:
528
574
  output_summary = tool_response.get("error", "error")[:200]
529
575
  else:
@@ -556,6 +602,7 @@ def record_event_to_sqlite(
556
602
  parent_event_id=parent_event_id,
557
603
  cost_tokens=0,
558
604
  subagent_type=subagent_type,
605
+ model=model,
559
606
  )
560
607
 
561
608
  if success:
@@ -573,7 +620,7 @@ def record_delegation_to_sqlite(
573
620
  from_agent: str,
574
621
  to_agent: str,
575
622
  task_description: str,
576
- task_input: dict,
623
+ task_input: dict[str, Any],
577
624
  ) -> str | None:
578
625
  """
579
626
  Record a Task() delegation to agent_collaboration table.
@@ -619,7 +666,7 @@ def record_delegation_to_sqlite(
619
666
  return None
620
667
 
621
668
 
622
- def track_event(hook_type: str, hook_input: dict) -> dict:
669
+ def track_event(hook_type: str, hook_input: dict[str, Any]) -> dict[str, Any]:
623
670
  """
624
671
  Track a hook event and log it to HtmlGraph (both HTML files and SQLite).
625
672
 
@@ -647,26 +694,79 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
647
694
  # Initialize SQLite database for event recording
648
695
  db = None
649
696
  try:
650
- db = HtmlGraphDB(str(graph_dir / "index.sqlite"))
697
+ from htmlgraph.config import get_database_path
698
+
699
+ db = HtmlGraphDB(str(get_database_path()))
651
700
  except Exception as e:
652
701
  print(f"Warning: Could not initialize SQLite database: {e}", file=sys.stderr)
653
702
  # Continue without SQLite (graceful degradation)
654
703
 
655
- # Detect agent from environment
656
- detected_agent = detect_agent_from_environment()
704
+ # Detect agent and model from environment
705
+ detected_agent, detected_model = detect_agent_from_environment()
657
706
 
658
- # Get active session ID
659
- active_session = manager.get_active_session()
660
- if not active_session:
661
- # No active HtmlGraph session yet; start one (stable internal id).
662
- try:
663
- active_session = manager.start_session(
664
- session_id=None,
665
- agent=detected_agent,
666
- title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
707
+ # Also try to detect model from hook input (more specific than environment)
708
+ model_from_input = detect_model_from_hook_input(hook_input)
709
+ if model_from_input:
710
+ detected_model = model_from_input
711
+
712
+ active_session = None
713
+
714
+ # Check if we're in a subagent context (environment variables set by spawner router)
715
+ # This MUST be checked BEFORE using get_active_session() to avoid attributing
716
+ # subagent events to the parent orchestrator session
717
+ subagent_type = os.environ.get("HTMLGRAPH_SUBAGENT_TYPE")
718
+ parent_session_id = os.environ.get("HTMLGRAPH_PARENT_SESSION")
719
+
720
+ if subagent_type and parent_session_id:
721
+ # We're in a subagent - create or get subagent session
722
+ # Use deterministic session ID based on parent + subagent type
723
+ subagent_session_id = f"{parent_session_id}-{subagent_type}"
724
+
725
+ # Check if subagent session already exists
726
+ existing = manager.session_converter.load(subagent_session_id)
727
+ if existing:
728
+ active_session = existing
729
+ print(
730
+ f"Debug: Using existing subagent session: {subagent_session_id}",
731
+ file=sys.stderr,
667
732
  )
668
- except Exception:
669
- return {"continue": True}
733
+ else:
734
+ # Create new subagent session with parent link
735
+ try:
736
+ active_session = manager.start_session(
737
+ session_id=subagent_session_id,
738
+ agent=f"{subagent_type}-spawner",
739
+ is_subagent=True,
740
+ parent_session_id=parent_session_id,
741
+ title=f"{subagent_type.capitalize()} Subagent",
742
+ )
743
+ print(
744
+ f"Debug: Created subagent session: {subagent_session_id} "
745
+ f"(parent: {parent_session_id})",
746
+ file=sys.stderr,
747
+ )
748
+ except Exception as e:
749
+ print(
750
+ f"Warning: Could not create subagent session: {e}",
751
+ file=sys.stderr,
752
+ )
753
+ return {"continue": True}
754
+
755
+ # Override detected agent for subagent context
756
+ detected_agent = f"{subagent_type}-spawner"
757
+ else:
758
+ # Normal orchestrator/parent context - use global session cache
759
+ active_session = manager.get_active_session()
760
+ if not active_session:
761
+ # No active HtmlGraph session yet; start one (stable internal id).
762
+ try:
763
+ active_session = manager.start_session(
764
+ session_id=None,
765
+ agent=detected_agent,
766
+ title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
767
+ )
768
+ except Exception:
769
+ return {"continue": True}
670
770
 
671
771
  active_session_id = active_session.id
672
772
 
@@ -732,6 +832,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
732
832
  tool_response={"content": "Agent stopped"},
733
833
  is_error=False,
734
834
  agent_id=detected_agent,
835
+ model=detected_model,
735
836
  )
736
837
  except Exception as e:
737
838
  print(f"Warning: Could not track stop: {e}", file=sys.stderr)
@@ -749,10 +850,11 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
749
850
  session_id=active_session_id, tool="UserQuery", summary=f'"{preview}"'
750
851
  )
751
852
 
752
- # Record to SQLite if available and capture event_id for parent-child linking
753
- user_query_event_id = None
853
+ # Record to SQLite if available
854
+ # UserQuery event is stored in database - no file-based state needed
855
+ # Subsequent tool calls query database for parent via get_parent_user_query()
754
856
  if db:
755
- user_query_event_id = record_event_to_sqlite(
857
+ record_event_to_sqlite(
756
858
  db=db,
757
859
  session_id=active_session_id,
758
860
  tool_name="UserQuery",
@@ -760,14 +862,9 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
760
862
  tool_response={"content": "Query received"},
761
863
  is_error=False,
762
864
  agent_id=detected_agent,
865
+ model=detected_model,
763
866
  )
764
867
 
765
- # Store the UserQuery event_id for subsequent tool calls to use as parent
766
- if user_query_event_id:
767
- save_user_query_event(
768
- graph_dir, active_session_id, user_query_event_id
769
- )
770
-
771
868
  except Exception as e:
772
869
  print(f"Warning: Could not track query: {e}", file=sys.stderr)
773
870
  return {"continue": True}
@@ -792,7 +889,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
792
889
  summary = format_tool_summary(tool_name, tool_input_data, tool_response)
793
890
 
794
891
  # Determine success
795
- if isinstance(tool_response, dict):
892
+ if isinstance(tool_response, dict): # type: ignore[arg-type]
796
893
  success_field = tool_response.get("success")
797
894
  if isinstance(success_field, bool):
798
895
  is_error = not success_field
@@ -818,37 +915,20 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
818
915
  warning_threshold = drift_settings.get("warning_threshold") or 0.7
819
916
  auto_classify_threshold = drift_settings.get("auto_classify_threshold") or 0.85
820
917
 
821
- # Determine parent activity context
822
- parent_activity_state = load_parent_activity(graph_dir)
918
+ # Determine parent activity context using database-only lookup
823
919
  parent_activity_id = None
824
920
 
825
- # Tools that create parent context (Skill, Task)
826
- parent_tools = {"Skill", "Task"}
827
-
828
- # If this is a parent tool invocation, save its context for subsequent activities
829
- if tool_name in parent_tools:
830
- # We'll get the event_id after tracking, so we use a placeholder for now
831
- # The actual parent_id will be set below after we track the activity
832
- is_parent_tool = True
833
- else:
834
- is_parent_tool = False
835
- # Check environment variable FIRST for cross-process parent linking
836
- # This is set by PreToolUse hook when Task() spawns a subagent
837
- env_parent = os.environ.get("HTMLGRAPH_PARENT_EVENT")
838
- if env_parent:
839
- parent_activity_id = env_parent
840
- # Next, check for UserQuery event as parent (for prompt-based grouping)
841
- # UserQuery takes priority over parent_activity_json to ensure each conversation turn
842
- # has its tool calls properly grouped together
843
- else:
844
- user_query_event_id = load_user_query_event(
845
- graph_dir, active_session_id
846
- )
847
- if user_query_event_id:
848
- parent_activity_id = user_query_event_id
849
- # Fall back to parent-activity.json only if no UserQuery event (backward compatibility)
850
- elif parent_activity_state.get("parent_id"):
851
- parent_activity_id = parent_activity_state["parent_id"]
921
+ # Check environment variable FIRST for cross-process parent linking
922
+ # This is set by PreToolUse hook when Task() spawns a subagent
923
+ env_parent = os.environ.get("HTMLGRAPH_PARENT_EVENT") or os.environ.get(
924
+ "HTMLGRAPH_PARENT_QUERY_EVENT"
925
+ )
926
+ if env_parent:
927
+ parent_activity_id = env_parent
928
+ # Query database for most recent UserQuery event as parent
929
+ # Database is the single source of truth for parent-child linking
930
+ elif db:
931
+ parent_activity_id = get_parent_user_query(db, active_session_id)
852
932
 
853
933
  # Track the activity
854
934
  nudge = None
@@ -882,6 +962,7 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
882
962
  parent_event_id=parent_activity_id, # Link to parent event
883
963
  agent_id=detected_agent,
884
964
  subagent_type=task_subagent_type,
965
+ model=detected_model,
885
966
  )
886
967
 
887
968
  # If this was a Task() delegation, also record to agent_collaboration
@@ -897,12 +978,6 @@ def track_event(hook_type: str, hook_input: dict) -> dict:
897
978
  task_input=tool_input_data,
898
979
  )
899
980
 
900
- # If this was a parent tool, save its ID for subsequent activities
901
- if is_parent_tool and result:
902
- save_parent_activity(graph_dir, result.id, tool_name)
903
- # If this tool finished a parent context (e.g., Task completed), clear it
904
- # We'll clear parent context after 5 minutes automatically (see load_parent_activity)
905
-
906
981
  # Check for drift and handle accordingly
907
982
  # Skip drift detection for child activities (they inherit parent's context)
908
983
  if result and hasattr(result, "drift_score") and not parent_activity_id: