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
@@ -3,10 +3,13 @@
3
3
  import json
4
4
  import os
5
5
  import subprocess
6
+ import sys
7
+ import time
6
8
  from dataclasses import dataclass
7
- from typing import TYPE_CHECKING
9
+ from typing import TYPE_CHECKING, Any
8
10
 
9
11
  if TYPE_CHECKING:
12
+ from htmlgraph.orchestration.live_events import LiveEventPublisher
10
13
  from htmlgraph.sdk import SDK
11
14
 
12
15
 
@@ -63,7 +66,92 @@ class HeadlessSpawner:
63
66
 
64
67
  def __init__(self) -> None:
65
68
  """Initialize spawner."""
66
- pass
69
+ self._live_publisher: LiveEventPublisher | None = None
70
+
71
+ def _get_live_publisher(self) -> "LiveEventPublisher | None":
72
+ """
73
+ Get LiveEventPublisher instance for real-time WebSocket streaming.
74
+
75
+ Returns None if publisher unavailable (optional dependency).
76
+ """
77
+ if self._live_publisher is None:
78
+ try:
79
+ from htmlgraph.orchestration.live_events import LiveEventPublisher
80
+
81
+ self._live_publisher = LiveEventPublisher()
82
+ except Exception:
83
+ # Live events are optional
84
+ pass
85
+ return self._live_publisher
86
+
87
+ def _publish_live_event(
88
+ self,
89
+ event_type: str,
90
+ spawner_type: str,
91
+ **kwargs: str | int | float | bool | None,
92
+ ) -> None:
93
+ """
94
+ Publish a live event for WebSocket streaming.
95
+
96
+ Silently fails if publisher unavailable (optional feature).
97
+ """
98
+ publisher = self._get_live_publisher()
99
+ if publisher is None:
100
+ return
101
+
102
+ parent_event_id = os.getenv("HTMLGRAPH_PARENT_EVENT")
103
+
104
+ try:
105
+ if event_type == "spawner_start":
106
+ publisher.spawner_start(
107
+ spawner_type=spawner_type,
108
+ prompt=str(kwargs.get("prompt", "")),
109
+ parent_event_id=parent_event_id,
110
+ model=str(kwargs.get("model", "")) if kwargs.get("model") else None,
111
+ )
112
+ elif event_type == "spawner_phase":
113
+ progress_val = kwargs.get("progress")
114
+ publisher.spawner_phase(
115
+ spawner_type=spawner_type,
116
+ phase=str(kwargs.get("phase", "executing")),
117
+ progress=int(progress_val) if progress_val is not None else None,
118
+ details=str(kwargs.get("details", ""))
119
+ if kwargs.get("details")
120
+ else None,
121
+ parent_event_id=parent_event_id,
122
+ )
123
+ elif event_type == "spawner_complete":
124
+ duration_val = kwargs.get("duration")
125
+ tokens_val = kwargs.get("tokens")
126
+ publisher.spawner_complete(
127
+ spawner_type=spawner_type,
128
+ success=bool(kwargs.get("success", False)),
129
+ duration_seconds=float(duration_val)
130
+ if duration_val is not None
131
+ else None,
132
+ response_preview=str(kwargs.get("response", ""))[:200]
133
+ if kwargs.get("response")
134
+ else None,
135
+ tokens_used=int(tokens_val) if tokens_val is not None else None,
136
+ error=str(kwargs.get("error", "")) if kwargs.get("error") else None,
137
+ parent_event_id=parent_event_id,
138
+ )
139
+ elif event_type == "spawner_tool_use":
140
+ publisher.spawner_tool_use(
141
+ spawner_type=spawner_type,
142
+ tool_name=str(kwargs.get("tool_name", "unknown")),
143
+ parent_event_id=parent_event_id,
144
+ )
145
+ elif event_type == "spawner_message":
146
+ publisher.spawner_message(
147
+ spawner_type=spawner_type,
148
+ message=str(kwargs.get("message", "")),
149
+ role=str(kwargs.get("role", "assistant")),
150
+ parent_event_id=parent_event_id,
151
+ )
152
+ except Exception:
153
+ # Live events should never break spawner execution
154
+ pass
67
155
 
68
156
  def _get_sdk(self) -> "SDK | None":
69
157
  """
@@ -376,6 +464,8 @@ class HeadlessSpawner:
376
464
  include_directories: list[str] | None = None,
377
465
  track_in_htmlgraph: bool = True,
378
466
  timeout: int = 120,
467
+ tracker: Any = None,
468
+ parent_event_id: str | None = None,
379
469
  ) -> AIResult:
380
470
  """
381
471
  Spawn Gemini in headless mode.
@@ -387,6 +477,8 @@ class HeadlessSpawner:
387
477
  include_directories: Directories to include for context. Default: None
388
478
  track_in_htmlgraph: Enable HtmlGraph activity tracking. Default: True
389
479
  timeout: Max seconds to wait
480
+ tracker: Optional SpawnerEventTracker for recording subprocess invocation
481
+ parent_event_id: Optional parent event ID for event hierarchy
390
482
 
391
483
  Returns:
392
484
  AIResult with response, error, and tracked events if tracking enabled
@@ -397,6 +489,15 @@ class HeadlessSpawner:
397
489
  if track_in_htmlgraph:
398
490
  sdk = self._get_sdk()
399
491
 
492
+ # Publish live event: spawner starting
493
+ self._publish_live_event(
494
+ "spawner_start",
495
+ "gemini",
496
+ prompt=prompt,
497
+ model=model,
498
+ )
499
+ start_time = time.time()
500
+
400
501
  try:
401
502
  # Build command based on tested pattern from spike spk-4029eef3
402
503
  cmd = ["gemini", "-p", prompt, "--output-format", output_format]
@@ -425,6 +526,53 @@ class HeadlessSpawner:
425
526
  # Tracking failure should not break execution
426
527
  pass
427
528
 
529
+ # Publish live event: executing
530
+ self._publish_live_event(
531
+ "spawner_phase",
532
+ "gemini",
533
+ phase="executing",
534
+ details="Running Gemini CLI",
535
+ )
536
+
537
+ # Record subprocess invocation if tracker is available
538
+ subprocess_event_id = None
539
+ print(
540
+ f"DEBUG: tracker={tracker is not None}, parent_event_id={parent_event_id}",
541
+ file=sys.stderr,
542
+ )
543
+ if tracker and parent_event_id:
544
+ print(
545
+ "DEBUG: Recording subprocess invocation for Gemini...",
546
+ file=sys.stderr,
547
+ )
548
+ try:
549
+ subprocess_event = tracker.record_tool_call(
550
+ tool_name="subprocess.gemini",
551
+ tool_input={"cmd": cmd},
552
+ phase_event_id=parent_event_id,
553
+ spawned_agent="gemini-2.0-flash",
554
+ )
555
+ if subprocess_event:
556
+ subprocess_event_id = subprocess_event.get("event_id")
557
+ print(
558
+ f"DEBUG: Subprocess event created for Gemini: {subprocess_event_id}",
559
+ file=sys.stderr,
560
+ )
561
+ else:
562
+ print("DEBUG: subprocess_event was None", file=sys.stderr)
563
+ except Exception as e:
564
+ # Tracking failure should not break execution
565
+ print(
566
+ f"DEBUG: Exception recording Gemini subprocess: {e}",
567
+ file=sys.stderr,
568
+ )
569
+ pass
570
+ else:
571
+ print(
572
+ f"DEBUG: Skipping Gemini subprocess tracking - tracker={tracker is not None}, parent_event_id={parent_event_id}",
573
+ file=sys.stderr,
574
+ )
575
+
428
576
  # Execute with timeout and stderr redirection
429
577
  # Note: Cannot use capture_output with stderr parameter
430
578
  result = subprocess.run(
@@ -435,8 +583,36 @@ class HeadlessSpawner:
435
583
  timeout=timeout,
436
584
  )
437
585
 
586
+ # Complete subprocess invocation tracking
587
+ if tracker and subprocess_event_id:
588
+ try:
589
+ tracker.complete_tool_call(
590
+ event_id=subprocess_event_id,
591
+ output_summary=result.stdout[:500] if result.stdout else "",
592
+ success=result.returncode == 0,
593
+ )
594
+ except Exception:
595
+ # Tracking failure should not break execution
596
+ pass
597
+
598
+ # Publish live event: processing response
599
+ self._publish_live_event(
600
+ "spawner_phase",
601
+ "gemini",
602
+ phase="processing",
603
+ details="Parsing Gemini response",
604
+ )
605
+
438
606
  # Check for command execution errors
439
607
  if result.returncode != 0:
608
+ duration = time.time() - start_time
609
+ self._publish_live_event(
610
+ "spawner_complete",
611
+ "gemini",
612
+ success=False,
613
+ duration=duration,
614
+ error=f"CLI failed with exit code {result.returncode}",
615
+ )
440
616
  return AIResult(
441
617
  success=False,
442
618
  response="",
@@ -455,16 +631,20 @@ class HeadlessSpawner:
455
631
  # Only use stream-json parsing if we got valid events
456
632
  if tracked_events:
457
633
  # For stream-json, we need to extract response differently
458
- # Look for the last message or result event
634
+ # Collect all assistant message content, then check result
459
635
  response_text = ""
460
636
  for event in tracked_events:
461
- if event.get("type") == "result":
462
- response_text = event.get("response", "")
463
- break
464
- elif event.get("type") == "message":
465
- content = event.get("content", "")
466
- if content:
467
- response_text = content
637
+ if event.get("type") == "message":
638
+ # Only collect assistant messages
639
+ if event.get("role") == "assistant":
640
+ content = event.get("content", "")
641
+ if content:
642
+ response_text += content
643
+ elif event.get("type") == "result":
644
+ # Result event may have response field (override if present)
645
+ if "response" in event and event["response"]:
646
+ response_text = event["response"]
647
+ # Don't break - we've already collected messages
468
648
 
469
649
  # Token usage from stats in result event
470
650
  tokens = None
@@ -481,6 +661,16 @@ class HeadlessSpawner:
481
661
  tokens = total_tokens if total_tokens > 0 else None
482
662
  break
483
663
 
664
+ # Publish live event: complete
665
+ duration = time.time() - start_time
666
+ self._publish_live_event(
667
+ "spawner_complete",
668
+ "gemini",
669
+ success=True,
670
+ duration=duration,
671
+ response=response_text,
672
+ tokens=tokens,
673
+ )
484
674
  return AIResult(
485
675
  success=True,
486
676
  response=response_text,
@@ -498,6 +688,14 @@ class HeadlessSpawner:
498
688
  try:
499
689
  output = json.loads(result.stdout)
500
690
  except json.JSONDecodeError as e:
691
+ duration = time.time() - start_time
692
+ self._publish_live_event(
693
+ "spawner_complete",
694
+ "gemini",
695
+ success=False,
696
+ duration=duration,
697
+ error=f"Failed to parse JSON: {e}",
698
+ )
501
699
  return AIResult(
502
700
  success=False,
503
701
  response="",
@@ -521,6 +719,16 @@ class HeadlessSpawner:
521
719
  total_tokens += model_tokens
522
720
  tokens = total_tokens if total_tokens > 0 else None
523
721
 
722
+ # Publish live event: complete
723
+ duration = time.time() - start_time
724
+ self._publish_live_event(
725
+ "spawner_complete",
726
+ "gemini",
727
+ success=True,
728
+ duration=duration,
729
+ response=response_text,
730
+ tokens=tokens,
731
+ )
524
732
  return AIResult(
525
733
  success=True,
526
734
  response=response_text,
@@ -531,6 +739,14 @@ class HeadlessSpawner:
531
739
  )
532
740
 
533
741
  except subprocess.TimeoutExpired as e:
742
+ duration = time.time() - start_time
743
+ self._publish_live_event(
744
+ "spawner_complete",
745
+ "gemini",
746
+ success=False,
747
+ duration=duration,
748
+ error=f"Timed out after {timeout} seconds",
749
+ )
534
750
  return AIResult(
535
751
  success=False,
536
752
  response="",
@@ -545,6 +761,14 @@ class HeadlessSpawner:
545
761
  tracked_events=tracked_events,
546
762
  )
547
763
  except FileNotFoundError:
764
+ duration = time.time() - start_time
765
+ self._publish_live_event(
766
+ "spawner_complete",
767
+ "gemini",
768
+ success=False,
769
+ duration=duration,
770
+ error="CLI not found",
771
+ )
548
772
  return AIResult(
549
773
  success=False,
550
774
  response="",
@@ -554,6 +778,14 @@ class HeadlessSpawner:
554
778
  tracked_events=tracked_events,
555
779
  )
556
780
  except Exception as e:
781
+ duration = time.time() - start_time
782
+ self._publish_live_event(
783
+ "spawner_complete",
784
+ "gemini",
785
+ success=False,
786
+ duration=duration,
787
+ error=str(e),
788
+ )
557
789
  return AIResult(
558
790
  success=False,
559
791
  response="",
@@ -579,6 +811,8 @@ class HeadlessSpawner:
579
811
  bypass_approvals: bool = False,
580
812
  track_in_htmlgraph: bool = True,
581
813
  timeout: int = 120,
814
+ tracker: Any = None,
815
+ parent_event_id: str | None = None,
582
816
  ) -> AIResult:
583
817
  """
584
818
  Spawn Codex in headless mode.
@@ -598,6 +832,8 @@ class HeadlessSpawner:
598
832
  bypass_approvals: Bypass approval checks. Default: False
599
833
  track_in_htmlgraph: Enable HtmlGraph activity tracking. Default: True
600
834
  timeout: Max seconds to wait
835
+ tracker: Optional SpawnerEventTracker for recording subprocess invocation
836
+ parent_event_id: Optional parent event ID for event hierarchy
601
837
 
602
838
  Returns:
603
839
  AIResult with response, error, and tracked events if tracking enabled
@@ -608,6 +844,15 @@ class HeadlessSpawner:
608
844
  if track_in_htmlgraph and output_json:
609
845
  sdk = self._get_sdk()
610
846
 
847
+ # Publish live event: spawner starting
848
+ self._publish_live_event(
849
+ "spawner_start",
850
+ "codex",
851
+ prompt=prompt,
852
+ model=model,
853
+ )
854
+ start_time = time.time()
855
+
611
856
  cmd = ["codex", "exec"]
612
857
 
613
858
  if output_json:
@@ -674,6 +919,53 @@ class HeadlessSpawner:
674
919
  pass
675
920
 
676
921
  try:
922
+ # Publish live event: executing
923
+ self._publish_live_event(
924
+ "spawner_phase",
925
+ "codex",
926
+ phase="executing",
927
+ details="Running Codex CLI",
928
+ )
929
+
930
+ # Record subprocess invocation if tracker is available
931
+ subprocess_event_id = None
932
+ print(
933
+ f"DEBUG: tracker={tracker is not None}, parent_event_id={parent_event_id}",
934
+ file=sys.stderr,
935
+ )
936
+ if tracker and parent_event_id:
937
+ print(
938
+ "DEBUG: Recording subprocess invocation for Codex...",
939
+ file=sys.stderr,
940
+ )
941
+ try:
942
+ subprocess_event = tracker.record_tool_call(
943
+ tool_name="subprocess.codex",
944
+ tool_input={"cmd": cmd},
945
+ phase_event_id=parent_event_id,
946
+ spawned_agent="gpt-4",
947
+ )
948
+ if subprocess_event:
949
+ subprocess_event_id = subprocess_event.get("event_id")
950
+ print(
951
+ f"DEBUG: Subprocess event created for Codex: {subprocess_event_id}",
952
+ file=sys.stderr,
953
+ )
954
+ else:
955
+ print("DEBUG: subprocess_event was None", file=sys.stderr)
956
+ except Exception as e:
957
+ # Tracking failure should not break execution
958
+ print(
959
+ f"DEBUG: Exception recording Codex subprocess: {e}",
960
+ file=sys.stderr,
961
+ )
962
+ pass
963
+ else:
964
+ print(
965
+ f"DEBUG: Skipping Codex subprocess tracking - tracker={tracker is not None}, parent_event_id={parent_event_id}",
966
+ file=sys.stderr,
967
+ )
968
+
677
969
  result = subprocess.run(
678
970
  cmd,
679
971
  stdout=subprocess.PIPE,
@@ -682,13 +974,43 @@ class HeadlessSpawner:
682
974
  timeout=timeout,
683
975
  )
684
976
 
977
+ # Complete subprocess invocation tracking
978
+ if tracker and subprocess_event_id:
979
+ try:
980
+ tracker.complete_tool_call(
981
+ event_id=subprocess_event_id,
982
+ output_summary=result.stdout[:500] if result.stdout else "",
983
+ success=result.returncode == 0,
984
+ )
985
+ except Exception:
986
+ # Tracking failure should not break execution
987
+ pass
988
+
989
+ # Publish live event: processing
990
+ self._publish_live_event(
991
+ "spawner_phase",
992
+ "codex",
993
+ phase="processing",
994
+ details="Parsing Codex response",
995
+ )
996
+
685
997
  if not output_json:
686
998
  # Plain text mode - return as-is
999
+ duration = time.time() - start_time
1000
+ success = result.returncode == 0
1001
+ self._publish_live_event(
1002
+ "spawner_complete",
1003
+ "codex",
1004
+ success=success,
1005
+ duration=duration,
1006
+ response=result.stdout.strip()[:200] if success else None,
1007
+ error="Command failed" if not success else None,
1008
+ )
687
1009
  return AIResult(
688
- success=result.returncode == 0,
1010
+ success=success,
689
1011
  response=result.stdout.strip(),
690
1012
  tokens_used=None,
691
- error=None if result.returncode == 0 else "Command failed",
1013
+ error=None if success else "Command failed",
692
1014
  raw_output=result.stdout,
693
1015
  tracked_events=tracked_events,
694
1016
  )
@@ -735,11 +1057,23 @@ class HeadlessSpawner:
735
1057
  # Sum all token types
736
1058
  tokens = sum(usage.values())
737
1059
 
1060
+ # Publish live event: complete
1061
+ duration = time.time() - start_time
1062
+ success = result.returncode == 0
1063
+ self._publish_live_event(
1064
+ "spawner_complete",
1065
+ "codex",
1066
+ success=success,
1067
+ duration=duration,
1068
+ response=response[:200] if response else None,
1069
+ tokens=tokens,
1070
+ error="Command failed" if not success else None,
1071
+ )
738
1072
  return AIResult(
739
- success=result.returncode == 0,
1073
+ success=success,
740
1074
  response=response or "",
741
1075
  tokens_used=tokens,
742
- error=None if result.returncode == 0 else "Command failed",
1076
+ error=None if success else "Command failed",
743
1077
  raw_output={
744
1078
  "events": events,
745
1079
  "parse_errors": parse_errors if parse_errors else None,
@@ -748,6 +1082,14 @@ class HeadlessSpawner:
748
1082
  )
749
1083
 
750
1084
  except FileNotFoundError:
1085
+ duration = time.time() - start_time
1086
+ self._publish_live_event(
1087
+ "spawner_complete",
1088
+ "codex",
1089
+ success=False,
1090
+ duration=duration,
1091
+ error="CLI not found",
1092
+ )
751
1093
  return AIResult(
752
1094
  success=False,
753
1095
  response="",
@@ -757,6 +1099,14 @@ class HeadlessSpawner:
757
1099
  tracked_events=tracked_events,
758
1100
  )
759
1101
  except subprocess.TimeoutExpired as e:
1102
+ duration = time.time() - start_time
1103
+ self._publish_live_event(
1104
+ "spawner_complete",
1105
+ "codex",
1106
+ success=False,
1107
+ duration=duration,
1108
+ error=f"Timed out after {timeout} seconds",
1109
+ )
760
1110
  return AIResult(
761
1111
  success=False,
762
1112
  response="",
@@ -771,6 +1121,14 @@ class HeadlessSpawner:
771
1121
  tracked_events=tracked_events,
772
1122
  )
773
1123
  except Exception as e:
1124
+ duration = time.time() - start_time
1125
+ self._publish_live_event(
1126
+ "spawner_complete",
1127
+ "codex",
1128
+ success=False,
1129
+ duration=duration,
1130
+ error=str(e),
1131
+ )
774
1132
  return AIResult(
775
1133
  success=False,
776
1134
  response="",
@@ -788,6 +1146,8 @@ class HeadlessSpawner:
788
1146
  deny_tools: list[str] | None = None,
789
1147
  track_in_htmlgraph: bool = True,
790
1148
  timeout: int = 120,
1149
+ tracker: Any = None,
1150
+ parent_event_id: str | None = None,
791
1151
  ) -> AIResult:
792
1152
  """
793
1153
  Spawn GitHub Copilot in headless mode.
@@ -799,6 +1159,8 @@ class HeadlessSpawner:
799
1159
  deny_tools: Tools to deny (--deny-tool). Default: None
800
1160
  track_in_htmlgraph: Enable HtmlGraph activity tracking. Default: True
801
1161
  timeout: Max seconds to wait
1162
+ tracker: Optional SpawnerEventTracker for recording subprocess invocation
1163
+ parent_event_id: Optional parent event ID for event hierarchy
802
1164
 
803
1165
  Returns:
804
1166
  AIResult with response, error, and tracked events if tracking enabled
@@ -809,6 +1171,14 @@ class HeadlessSpawner:
809
1171
  if track_in_htmlgraph:
810
1172
  sdk = self._get_sdk()
811
1173
 
1174
+ # Publish live event: spawner starting
1175
+ self._publish_live_event(
1176
+ "spawner_start",
1177
+ "copilot",
1178
+ prompt=prompt,
1179
+ )
1180
+ start_time = time.time()
1181
+
812
1182
  cmd = ["copilot", "-p", prompt]
813
1183
 
814
1184
  # Add allow all tools flag
@@ -838,6 +1208,53 @@ class HeadlessSpawner:
838
1208
  pass
839
1209
 
840
1210
  try:
1211
+ # Publish live event: executing
1212
+ self._publish_live_event(
1213
+ "spawner_phase",
1214
+ "copilot",
1215
+ phase="executing",
1216
+ details="Running Copilot CLI",
1217
+ )
1218
+
1219
+ # Record subprocess invocation if tracker is available
1220
+ subprocess_event_id = None
1221
+ print(
1222
+ f"DEBUG: tracker={tracker is not None}, parent_event_id={parent_event_id}",
1223
+ file=sys.stderr,
1224
+ )
1225
+ if tracker and parent_event_id:
1226
+ print(
1227
+ "DEBUG: Recording subprocess invocation for Copilot...",
1228
+ file=sys.stderr,
1229
+ )
1230
+ try:
1231
+ subprocess_event = tracker.record_tool_call(
1232
+ tool_name="subprocess.copilot",
1233
+ tool_input={"cmd": cmd},
1234
+ phase_event_id=parent_event_id,
1235
+ spawned_agent="github-copilot",
1236
+ )
1237
+ if subprocess_event:
1238
+ subprocess_event_id = subprocess_event.get("event_id")
1239
+ print(
1240
+ f"DEBUG: Subprocess event created for Copilot: {subprocess_event_id}",
1241
+ file=sys.stderr,
1242
+ )
1243
+ else:
1244
+ print("DEBUG: subprocess_event was None", file=sys.stderr)
1245
+ except Exception as e:
1246
+ # Tracking failure should not break execution
1247
+ print(
1248
+ f"DEBUG: Exception recording Copilot subprocess: {e}",
1249
+ file=sys.stderr,
1250
+ )
1251
+ pass
1252
+ else:
1253
+ print(
1254
+ f"DEBUG: Skipping Copilot subprocess tracking - tracker={tracker is not None}, parent_event_id={parent_event_id}",
1255
+ file=sys.stderr,
1256
+ )
1257
+
841
1258
  result = subprocess.run(
842
1259
  cmd,
843
1260
  capture_output=True,
@@ -845,6 +1262,26 @@ class HeadlessSpawner:
845
1262
  timeout=timeout,
846
1263
  )
847
1264
 
1265
+ # Complete subprocess invocation tracking
1266
+ if tracker and subprocess_event_id:
1267
+ try:
1268
+ tracker.complete_tool_call(
1269
+ event_id=subprocess_event_id,
1270
+ output_summary=result.stdout[:500] if result.stdout else "",
1271
+ success=result.returncode == 0,
1272
+ )
1273
+ except Exception:
1274
+ # Tracking failure should not break execution
1275
+ pass
1276
+
1277
+ # Publish live event: processing
1278
+ self._publish_live_event(
1279
+ "spawner_phase",
1280
+ "copilot",
1281
+ phase="processing",
1282
+ details="Parsing Copilot response",
1283
+ )
1284
+
848
1285
  # Parse output: response is before stats block
849
1286
  lines = result.stdout.split("\n")
850
1287
 
@@ -874,16 +1311,36 @@ class HeadlessSpawner:
874
1311
  prompt, response, sdk
875
1312
  )
876
1313
 
1314
+ # Publish live event: complete
1315
+ duration = time.time() - start_time
1316
+ success = result.returncode == 0
1317
+ self._publish_live_event(
1318
+ "spawner_complete",
1319
+ "copilot",
1320
+ success=success,
1321
+ duration=duration,
1322
+ response=response[:200] if response else None,
1323
+ tokens=tokens,
1324
+ error=result.stderr if not success else None,
1325
+ )
877
1326
  return AIResult(
878
- success=result.returncode == 0,
1327
+ success=success,
879
1328
  response=response,
880
1329
  tokens_used=tokens,
881
- error=None if result.returncode == 0 else result.stderr,
1330
+ error=None if success else result.stderr,
882
1331
  raw_output=result.stdout,
883
1332
  tracked_events=tracked_events,
884
1333
  )
885
1334
 
886
1335
  except FileNotFoundError:
1336
+ duration = time.time() - start_time
1337
+ self._publish_live_event(
1338
+ "spawner_complete",
1339
+ "copilot",
1340
+ success=False,
1341
+ duration=duration,
1342
+ error="CLI not found",
1343
+ )
887
1344
  return AIResult(
888
1345
  success=False,
889
1346
  response="",
@@ -893,6 +1350,14 @@ class HeadlessSpawner:
893
1350
  tracked_events=tracked_events,
894
1351
  )
895
1352
  except subprocess.TimeoutExpired as e:
1353
+ duration = time.time() - start_time
1354
+ self._publish_live_event(
1355
+ "spawner_complete",
1356
+ "copilot",
1357
+ success=False,
1358
+ duration=duration,
1359
+ error=f"Timed out after {timeout} seconds",
1360
+ )
896
1361
  return AIResult(
897
1362
  success=False,
898
1363
  response="",
@@ -907,6 +1372,14 @@ class HeadlessSpawner:
907
1372
  tracked_events=tracked_events,
908
1373
  )
909
1374
  except Exception as e:
1375
+ duration = time.time() - start_time
1376
+ self._publish_live_event(
1377
+ "spawner_complete",
1378
+ "copilot",
1379
+ success=False,
1380
+ duration=duration,
1381
+ error=str(e),
1382
+ )
910
1383
  return AIResult(
911
1384
  success=False,
912
1385
  response="",