htmlgraph 0.23.5__py3-none-any.whl → 0.24.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 (29) hide show
  1. htmlgraph/__init__.py +5 -1
  2. htmlgraph/cigs/__init__.py +77 -0
  3. htmlgraph/cigs/autonomy.py +385 -0
  4. htmlgraph/cigs/cost.py +475 -0
  5. htmlgraph/cigs/messages_basic.py +472 -0
  6. htmlgraph/cigs/messaging.py +365 -0
  7. htmlgraph/cigs/models.py +771 -0
  8. htmlgraph/cigs/pattern_storage.py +427 -0
  9. htmlgraph/cigs/patterns.py +503 -0
  10. htmlgraph/cigs/posttool_analyzer.py +234 -0
  11. htmlgraph/cigs/tracker.py +317 -0
  12. htmlgraph/cli.py +413 -11
  13. htmlgraph/hooks/cigs_pretool_enforcer.py +350 -0
  14. htmlgraph/hooks/posttooluse.py +50 -2
  15. htmlgraph/hooks/task_enforcer.py +60 -4
  16. htmlgraph/models.py +14 -1
  17. htmlgraph/orchestration/headless_spawner.py +519 -21
  18. htmlgraph/orchestrator-system-prompt-optimized.txt +259 -53
  19. htmlgraph/reflection.py +442 -0
  20. htmlgraph/sdk.py +26 -9
  21. {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.1.dist-info}/METADATA +2 -1
  22. {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.1.dist-info}/RECORD +29 -17
  23. {htmlgraph-0.23.5.data → htmlgraph-0.24.1.data}/data/htmlgraph/dashboard.html +0 -0
  24. {htmlgraph-0.23.5.data → htmlgraph-0.24.1.data}/data/htmlgraph/styles.css +0 -0
  25. {htmlgraph-0.23.5.data → htmlgraph-0.24.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  26. {htmlgraph-0.23.5.data → htmlgraph-0.24.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  27. {htmlgraph-0.23.5.data → htmlgraph-0.24.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  28. {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.1.dist-info}/WHEEL +0 -0
  29. {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.1.dist-info}/entry_points.txt +0 -0
@@ -1,8 +1,13 @@
1
1
  """Headless AI spawner for multi-AI orchestration."""
2
2
 
3
3
  import json
4
+ import os
4
5
  import subprocess
5
6
  from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from htmlgraph.sdk import SDK
6
11
 
7
12
 
8
13
  @dataclass
@@ -14,6 +19,7 @@ class AIResult:
14
19
  tokens_used: int | None
15
20
  error: str | None
16
21
  raw_output: dict | list | str | None
22
+ tracked_events: list[dict] | None = None # Events tracked in HtmlGraph
17
23
 
18
24
 
19
25
  class HeadlessSpawner:
@@ -59,12 +65,316 @@ class HeadlessSpawner:
59
65
  """Initialize spawner."""
60
66
  pass
61
67
 
68
+ def _get_sdk(self) -> "SDK | None":
69
+ """
70
+ Get SDK instance for HtmlGraph tracking with parent session support.
71
+
72
+ Returns None if SDK unavailable.
73
+ """
74
+ try:
75
+ from htmlgraph.sdk import SDK
76
+
77
+ # Read parent session context from environment
78
+ parent_session = os.getenv("HTMLGRAPH_PARENT_SESSION")
79
+ parent_agent = os.getenv("HTMLGRAPH_PARENT_AGENT")
80
+
81
+ # Create SDK with parent session context
82
+ sdk = SDK(
83
+ agent=f"spawner-{parent_agent}" if parent_agent else "spawner",
84
+ parent_session=parent_session, # Pass parent session
85
+ )
86
+
87
+ return sdk
88
+
89
+ except Exception:
90
+ # SDK unavailable or not properly initialized (optional dependency)
91
+ # This happens in test contexts without active sessions
92
+ # Don't log error to avoid noise in tests
93
+ return None
94
+
95
+ def _parse_and_track_gemini_events(
96
+ self, jsonl_output: str, sdk: "SDK"
97
+ ) -> list[dict]:
98
+ """
99
+ Parse Gemini stream-json events and track in HtmlGraph.
100
+
101
+ Args:
102
+ jsonl_output: JSONL output from Gemini CLI
103
+ sdk: HtmlGraph SDK instance for tracking
104
+
105
+ Returns:
106
+ Parsed events list
107
+ """
108
+ events = []
109
+
110
+ # Get parent context for metadata
111
+ parent_activity = os.getenv("HTMLGRAPH_PARENT_ACTIVITY")
112
+ nesting_depth_str = os.getenv("HTMLGRAPH_NESTING_DEPTH", "0")
113
+ nesting_depth = int(nesting_depth_str) if nesting_depth_str.isdigit() else 0
114
+
115
+ for line in jsonl_output.splitlines():
116
+ if not line.strip():
117
+ continue
118
+
119
+ try:
120
+ event = json.loads(line)
121
+ events.append(event)
122
+
123
+ # Track based on event type
124
+ event_type = event.get("type")
125
+
126
+ try:
127
+ if event_type == "tool_use":
128
+ tool_name = event.get("tool_name", "unknown_tool")
129
+ parameters = event.get("parameters", {})
130
+ payload = {
131
+ "tool_name": tool_name,
132
+ "parameters": parameters,
133
+ }
134
+ if parent_activity:
135
+ payload["parent_activity"] = parent_activity
136
+ if nesting_depth > 0:
137
+ payload["nesting_depth"] = nesting_depth
138
+ sdk.track_activity(
139
+ tool="gemini_tool_call",
140
+ summary=f"Gemini called {tool_name}",
141
+ payload=payload,
142
+ )
143
+
144
+ elif event_type == "tool_result":
145
+ status = event.get("status", "unknown")
146
+ success = status == "success"
147
+ tool_id = event.get("tool_id", "unknown")
148
+ payload = {"tool_id": tool_id, "status": status}
149
+ if parent_activity:
150
+ payload["parent_activity"] = parent_activity
151
+ if nesting_depth > 0:
152
+ payload["nesting_depth"] = nesting_depth
153
+ sdk.track_activity(
154
+ tool="gemini_tool_result",
155
+ summary=f"Gemini tool result: {status}",
156
+ success=success,
157
+ payload=payload,
158
+ )
159
+
160
+ elif event_type == "message":
161
+ role = event.get("role")
162
+ if role == "assistant":
163
+ content = event.get("content", "")
164
+ # Truncate for summary
165
+ summary = (
166
+ content[:100] + "..." if len(content) > 100 else content
167
+ )
168
+ payload = {"role": role, "content_length": len(content)}
169
+ if parent_activity:
170
+ payload["parent_activity"] = parent_activity
171
+ if nesting_depth > 0:
172
+ payload["nesting_depth"] = nesting_depth
173
+ sdk.track_activity(
174
+ tool="gemini_message",
175
+ summary=f"Gemini: {summary}",
176
+ payload=payload,
177
+ )
178
+
179
+ elif event_type == "result":
180
+ stats = event.get("stats", {})
181
+ payload = {"stats": stats}
182
+ if parent_activity:
183
+ payload["parent_activity"] = parent_activity
184
+ if nesting_depth > 0:
185
+ payload["nesting_depth"] = nesting_depth
186
+ sdk.track_activity(
187
+ tool="gemini_completion",
188
+ summary="Gemini task completed",
189
+ payload=payload,
190
+ )
191
+ except Exception:
192
+ # Tracking failure should not break parsing
193
+ pass
194
+
195
+ except json.JSONDecodeError:
196
+ # Skip malformed lines
197
+ continue
198
+
199
+ return events
200
+
201
+ def _parse_and_track_codex_events(
202
+ self, jsonl_output: str, sdk: "SDK"
203
+ ) -> list[dict]:
204
+ """
205
+ Parse Codex JSONL events and track in HtmlGraph.
206
+
207
+ Args:
208
+ jsonl_output: JSONL output from Codex CLI
209
+ sdk: HtmlGraph SDK instance for tracking
210
+
211
+ Returns:
212
+ Parsed events list
213
+ """
214
+ events = []
215
+ parse_errors = []
216
+
217
+ # Get parent context for metadata
218
+ parent_activity = os.getenv("HTMLGRAPH_PARENT_ACTIVITY")
219
+ nesting_depth_str = os.getenv("HTMLGRAPH_NESTING_DEPTH", "0")
220
+ nesting_depth = int(nesting_depth_str) if nesting_depth_str.isdigit() else 0
221
+
222
+ for line_num, line in enumerate(jsonl_output.splitlines(), start=1):
223
+ if not line.strip():
224
+ continue
225
+
226
+ try:
227
+ event = json.loads(line)
228
+ events.append(event)
229
+
230
+ event_type = event.get("type")
231
+
232
+ try:
233
+ # Track item.started events
234
+ if event_type == "item.started":
235
+ item = event.get("item", {})
236
+ item_type = item.get("type")
237
+
238
+ if item_type == "command_execution":
239
+ command = item.get("command", "")
240
+ payload = {"command": command}
241
+ if parent_activity:
242
+ payload["parent_activity"] = parent_activity
243
+ if nesting_depth > 0:
244
+ payload["nesting_depth"] = nesting_depth
245
+ sdk.track_activity(
246
+ tool="codex_command",
247
+ summary=f"Codex executing: {command[:80]}",
248
+ payload=payload,
249
+ )
250
+
251
+ # Track item.completed events
252
+ elif event_type == "item.completed":
253
+ item = event.get("item", {})
254
+ item_type = item.get("type")
255
+
256
+ if item_type == "file_change":
257
+ path = item.get("path", "unknown")
258
+ payload = {"path": path}
259
+ if parent_activity:
260
+ payload["parent_activity"] = parent_activity
261
+ if nesting_depth > 0:
262
+ payload["nesting_depth"] = nesting_depth
263
+ sdk.track_activity(
264
+ tool="codex_file_change",
265
+ summary=f"Codex modified: {path}",
266
+ file_paths=[path],
267
+ payload=payload,
268
+ )
269
+
270
+ elif item_type == "agent_message":
271
+ text = item.get("text", "")
272
+ summary = text[:100] + "..." if len(text) > 100 else text
273
+ payload = {"text_length": len(text)}
274
+ if parent_activity:
275
+ payload["parent_activity"] = parent_activity
276
+ if nesting_depth > 0:
277
+ payload["nesting_depth"] = nesting_depth
278
+ sdk.track_activity(
279
+ tool="codex_message",
280
+ summary=f"Codex: {summary}",
281
+ payload=payload,
282
+ )
283
+
284
+ # Track turn.completed for token usage
285
+ elif event_type == "turn.completed":
286
+ usage = event.get("usage", {})
287
+ total_tokens = sum(usage.values())
288
+ payload = {"usage": usage}
289
+ if parent_activity:
290
+ payload["parent_activity"] = parent_activity
291
+ if nesting_depth > 0:
292
+ payload["nesting_depth"] = nesting_depth
293
+ sdk.track_activity(
294
+ tool="codex_completion",
295
+ summary=f"Codex turn completed ({total_tokens} tokens)",
296
+ payload=payload,
297
+ )
298
+ except Exception:
299
+ # Tracking failure should not break parsing
300
+ pass
301
+
302
+ except json.JSONDecodeError as e:
303
+ parse_errors.append(
304
+ {
305
+ "line_number": line_num,
306
+ "error": str(e),
307
+ "content": line[:100],
308
+ }
309
+ )
310
+ continue
311
+
312
+ return events
313
+
314
+ def _parse_and_track_copilot_events(
315
+ self, prompt: str, response: str, sdk: "SDK"
316
+ ) -> list[dict]:
317
+ """
318
+ Track Copilot execution (start and result only).
319
+
320
+ Args:
321
+ prompt: Original prompt
322
+ response: Response from Copilot
323
+ sdk: HtmlGraph SDK instance for tracking
324
+
325
+ Returns:
326
+ Synthetic events list for consistency
327
+ """
328
+ events = []
329
+
330
+ # Get parent context for metadata
331
+ parent_activity = os.getenv("HTMLGRAPH_PARENT_ACTIVITY")
332
+ nesting_depth_str = os.getenv("HTMLGRAPH_NESTING_DEPTH", "0")
333
+ nesting_depth = int(nesting_depth_str) if nesting_depth_str.isdigit() else 0
334
+
335
+ try:
336
+ # Track start
337
+ start_event = {"type": "copilot_start", "prompt": prompt[:100]}
338
+ events.append(start_event)
339
+ payload: dict[str, str | int] = {"prompt_length": len(prompt)}
340
+ if parent_activity:
341
+ payload["parent_activity"] = parent_activity
342
+ if nesting_depth > 0:
343
+ payload["nesting_depth"] = nesting_depth
344
+ sdk.track_activity(
345
+ tool="copilot_start",
346
+ summary=f"Copilot started with prompt: {prompt[:80]}",
347
+ payload=payload,
348
+ )
349
+ except Exception:
350
+ pass
351
+
352
+ try:
353
+ # Track result
354
+ result_event = {"type": "copilot_result", "response": response[:100]}
355
+ events.append(result_event)
356
+ payload_result: dict[str, str | int] = {"response_length": len(response)}
357
+ if parent_activity:
358
+ payload_result["parent_activity"] = parent_activity
359
+ if nesting_depth > 0:
360
+ payload_result["nesting_depth"] = nesting_depth
361
+ sdk.track_activity(
362
+ tool="copilot_result",
363
+ summary=f"Copilot completed: {response[:80]}",
364
+ payload=payload_result,
365
+ )
366
+ except Exception:
367
+ pass
368
+
369
+ return events
370
+
62
371
  def spawn_gemini(
63
372
  self,
64
373
  prompt: str,
65
- output_format: str = "json",
374
+ output_format: str = "stream-json",
66
375
  model: str | None = None,
67
376
  include_directories: list[str] | None = None,
377
+ track_in_htmlgraph: bool = True,
68
378
  timeout: int = 120,
69
379
  ) -> AIResult:
70
380
  """
@@ -72,14 +382,21 @@ class HeadlessSpawner:
72
382
 
73
383
  Args:
74
384
  prompt: Task description for Gemini
75
- output_format: "json" or "stream-json"
385
+ output_format: "json" or "stream-json" (default: "stream-json" for real-time tracking)
76
386
  model: Model selection (e.g., "gemini-2.0-flash"). Default: None (uses default)
77
387
  include_directories: List of directories to include for context. Default: None
388
+ track_in_htmlgraph: Enable HtmlGraph activity tracking. Default: True
78
389
  timeout: Max seconds to wait
79
390
 
80
391
  Returns:
81
- AIResult with response or error
392
+ AIResult with response or error and tracked events if tracking enabled
82
393
  """
394
+ # Initialize tracking if enabled
395
+ sdk: SDK | None = None
396
+ tracked_events: list[dict] = []
397
+ if track_in_htmlgraph:
398
+ sdk = self._get_sdk()
399
+
83
400
  try:
84
401
  # Build command based on tested pattern from spike spk-4029eef3
85
402
  cmd = ["gemini", "-p", prompt, "--output-format", output_format]
@@ -96,6 +413,18 @@ class HeadlessSpawner:
96
413
  # CRITICAL: Add --yolo for headless mode (auto-approve all tools)
97
414
  cmd.append("--yolo")
98
415
 
416
+ # Track spawner start if SDK available
417
+ if sdk:
418
+ try:
419
+ sdk.track_activity(
420
+ tool="gemini_spawn_start",
421
+ summary=f"Spawning Gemini: {prompt[:80]}",
422
+ payload={"prompt_length": len(prompt), "model": model},
423
+ )
424
+ except Exception:
425
+ # Tracking failure should not break execution
426
+ pass
427
+
99
428
  # Execute with timeout and stderr redirection
100
429
  # Note: Cannot use capture_output with stderr parameter
101
430
  result = subprocess.run(
@@ -114,9 +443,58 @@ class HeadlessSpawner:
114
443
  tokens_used=None,
115
444
  error=f"Gemini CLI failed with exit code {result.returncode}",
116
445
  raw_output=None,
446
+ tracked_events=tracked_events,
117
447
  )
118
448
 
119
- # Parse JSON response
449
+ # Handle stream-json format with real-time tracking
450
+ if output_format == "stream-json" and sdk:
451
+ try:
452
+ tracked_events = self._parse_and_track_gemini_events(
453
+ result.stdout, sdk
454
+ )
455
+ # Only use stream-json parsing if we got valid events
456
+ if tracked_events:
457
+ # For stream-json, we need to extract response differently
458
+ # Look for the last message or result event
459
+ response_text = ""
460
+ 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
468
+
469
+ # Token usage from stats in result event
470
+ tokens = None
471
+ for event in tracked_events:
472
+ if event.get("type") == "result":
473
+ stats = event.get("stats", {})
474
+ if stats and "models" in stats:
475
+ total_tokens = 0
476
+ for model_stats in stats["models"].values():
477
+ model_tokens = model_stats.get(
478
+ "tokens", {}
479
+ ).get("total", 0)
480
+ total_tokens += model_tokens
481
+ tokens = total_tokens if total_tokens > 0 else None
482
+ break
483
+
484
+ return AIResult(
485
+ success=True,
486
+ response=response_text,
487
+ tokens_used=tokens,
488
+ error=None,
489
+ raw_output={"events": tracked_events},
490
+ tracked_events=tracked_events,
491
+ )
492
+
493
+ except Exception:
494
+ # Fall back to regular JSON parsing if tracking fails
495
+ pass
496
+
497
+ # Parse JSON response (for json format or fallback)
120
498
  try:
121
499
  output = json.loads(result.stdout)
122
500
  except json.JSONDecodeError as e:
@@ -126,6 +504,7 @@ class HeadlessSpawner:
126
504
  tokens_used=None,
127
505
  error=f"Failed to parse JSON output: {e}",
128
506
  raw_output={"stdout": result.stdout},
507
+ tracked_events=tracked_events,
129
508
  )
130
509
 
131
510
  # Extract response and token usage from parsed output
@@ -148,15 +527,22 @@ class HeadlessSpawner:
148
527
  tokens_used=tokens,
149
528
  error=None,
150
529
  raw_output=output,
530
+ tracked_events=tracked_events,
151
531
  )
152
532
 
153
- except subprocess.TimeoutExpired:
533
+ except subprocess.TimeoutExpired as e:
154
534
  return AIResult(
155
535
  success=False,
156
536
  response="",
157
537
  tokens_used=None,
158
538
  error=f"Gemini CLI timed out after {timeout} seconds",
159
- raw_output=None,
539
+ raw_output={
540
+ "partial_stdout": e.stdout.decode() if e.stdout else None,
541
+ "partial_stderr": e.stderr.decode() if e.stderr else None,
542
+ }
543
+ if e.stdout or e.stderr
544
+ else None,
545
+ tracked_events=tracked_events,
160
546
  )
161
547
  except FileNotFoundError:
162
548
  return AIResult(
@@ -165,6 +551,7 @@ class HeadlessSpawner:
165
551
  tokens_used=None,
166
552
  error="Gemini CLI not found. Ensure 'gemini' is installed and in PATH.",
167
553
  raw_output=None,
554
+ tracked_events=tracked_events,
168
555
  )
169
556
  except Exception as e:
170
557
  return AIResult(
@@ -173,6 +560,7 @@ class HeadlessSpawner:
173
560
  tokens_used=None,
174
561
  error=f"Unexpected error: {type(e).__name__}: {e}",
175
562
  raw_output=None,
563
+ tracked_events=tracked_events,
176
564
  )
177
565
 
178
566
  def spawn_codex(
@@ -189,6 +577,7 @@ class HeadlessSpawner:
189
577
  working_directory: str | None = None,
190
578
  use_oss: bool = False,
191
579
  bypass_approvals: bool = False,
580
+ track_in_htmlgraph: bool = True,
192
581
  timeout: int = 120,
193
582
  ) -> AIResult:
194
583
  """
@@ -196,7 +585,7 @@ class HeadlessSpawner:
196
585
 
197
586
  Args:
198
587
  prompt: Task description for Codex
199
- output_json: Use --json flag for JSONL output
588
+ output_json: Use --json flag for JSONL output (enables real-time tracking)
200
589
  model: Model selection (e.g., "gpt-4-turbo"). Default: None
201
590
  sandbox: Sandbox mode ("read-only", "workspace-write", "danger-full-access"). Default: None
202
591
  full_auto: Enable full auto mode (--full-auto). Default: True (required for headless)
@@ -207,11 +596,18 @@ class HeadlessSpawner:
207
596
  working_directory: Workspace directory (--cd). Default: None
208
597
  use_oss: Use local Ollama provider (--oss). Default: False
209
598
  bypass_approvals: Dangerously bypass approvals (--dangerously-bypass-approvals-and-sandbox). Default: False
599
+ track_in_htmlgraph: Enable HtmlGraph activity tracking. Default: True
210
600
  timeout: Max seconds to wait
211
601
 
212
602
  Returns:
213
- AIResult with response or error
603
+ AIResult with response, error, and tracked events if tracking enabled
214
604
  """
605
+ # Initialize tracking if enabled
606
+ sdk: SDK | None = None
607
+ tracked_events: list[dict] = []
608
+ if track_in_htmlgraph and output_json:
609
+ sdk = self._get_sdk()
610
+
215
611
  cmd = ["codex", "exec"]
216
612
 
217
613
  if output_json:
@@ -261,6 +657,22 @@ class HeadlessSpawner:
261
657
  # Add prompt as final argument
262
658
  cmd.append(prompt)
263
659
 
660
+ # Track spawner start if SDK available
661
+ if sdk:
662
+ try:
663
+ sdk.track_activity(
664
+ tool="codex_spawn_start",
665
+ summary=f"Spawning Codex: {prompt[:80]}",
666
+ payload={
667
+ "prompt_length": len(prompt),
668
+ "model": model,
669
+ "sandbox": sandbox,
670
+ },
671
+ )
672
+ except Exception:
673
+ # Tracking failure should not break execution
674
+ pass
675
+
264
676
  try:
265
677
  result = subprocess.run(
266
678
  cmd,
@@ -278,16 +690,34 @@ class HeadlessSpawner:
278
690
  tokens_used=None,
279
691
  error=None if result.returncode == 0 else "Command failed",
280
692
  raw_output=result.stdout,
693
+ tracked_events=tracked_events,
281
694
  )
282
695
 
283
696
  # Parse JSONL output
284
697
  events = []
285
- for line in result.stdout.splitlines():
286
- if line.strip():
287
- try:
288
- events.append(json.loads(line))
289
- except json.JSONDecodeError:
290
- continue
698
+ parse_errors = []
699
+
700
+ # Use tracking parser if SDK is available
701
+ if sdk:
702
+ tracked_events = self._parse_and_track_codex_events(result.stdout, sdk)
703
+ events = tracked_events
704
+ else:
705
+ # Fallback to regular parsing without tracking
706
+ for line_num, line in enumerate(result.stdout.splitlines(), start=1):
707
+ if line.strip():
708
+ try:
709
+ events.append(json.loads(line))
710
+ except json.JSONDecodeError as e:
711
+ parse_errors.append(
712
+ {
713
+ "line_number": line_num,
714
+ "error": str(e),
715
+ "content": line[
716
+ :100
717
+ ], # First 100 chars for debugging
718
+ }
719
+ )
720
+ continue
291
721
 
292
722
  # Extract agent message
293
723
  response = None
@@ -310,7 +740,11 @@ class HeadlessSpawner:
310
740
  response=response or "",
311
741
  tokens_used=tokens,
312
742
  error=None if result.returncode == 0 else "Command failed",
313
- raw_output=events,
743
+ raw_output={
744
+ "events": events,
745
+ "parse_errors": parse_errors if parse_errors else None,
746
+ },
747
+ tracked_events=tracked_events,
314
748
  )
315
749
 
316
750
  except FileNotFoundError:
@@ -320,14 +754,30 @@ class HeadlessSpawner:
320
754
  tokens_used=None,
321
755
  error="Codex CLI not found. Install from: https://github.com/openai/codex",
322
756
  raw_output=None,
757
+ tracked_events=tracked_events,
323
758
  )
324
- except subprocess.TimeoutExpired:
759
+ except subprocess.TimeoutExpired as e:
325
760
  return AIResult(
326
761
  success=False,
327
762
  response="",
328
763
  tokens_used=None,
329
764
  error=f"Timed out after {timeout} seconds",
765
+ raw_output={
766
+ "partial_stdout": e.stdout.decode() if e.stdout else None,
767
+ "partial_stderr": e.stderr.decode() if e.stderr else None,
768
+ }
769
+ if e.stdout or e.stderr
770
+ else None,
771
+ tracked_events=tracked_events,
772
+ )
773
+ except Exception as e:
774
+ return AIResult(
775
+ success=False,
776
+ response="",
777
+ tokens_used=None,
778
+ error=f"Unexpected error: {type(e).__name__}: {e}",
330
779
  raw_output=None,
780
+ tracked_events=tracked_events,
331
781
  )
332
782
 
333
783
  def spawn_copilot(
@@ -336,6 +786,7 @@ class HeadlessSpawner:
336
786
  allow_tools: list[str] | None = None,
337
787
  allow_all_tools: bool = False,
338
788
  deny_tools: list[str] | None = None,
789
+ track_in_htmlgraph: bool = True,
339
790
  timeout: int = 120,
340
791
  ) -> AIResult:
341
792
  """
@@ -346,11 +797,18 @@ class HeadlessSpawner:
346
797
  allow_tools: List of tools to auto-approve (e.g., ["shell(git)", "write(*.py)"])
347
798
  allow_all_tools: Auto-approve all tools (--allow-all-tools). Default: False
348
799
  deny_tools: List of tools to deny (--deny-tool). Default: None
800
+ track_in_htmlgraph: Enable HtmlGraph activity tracking. Default: True
349
801
  timeout: Max seconds to wait
350
802
 
351
803
  Returns:
352
- AIResult with response or error
804
+ AIResult with response, error, and tracked events if tracking enabled
353
805
  """
806
+ # Initialize tracking if enabled
807
+ sdk = None
808
+ tracked_events = []
809
+ if track_in_htmlgraph:
810
+ sdk = self._get_sdk()
811
+
354
812
  cmd = ["copilot", "-p", prompt]
355
813
 
356
814
  # Add allow all tools flag
@@ -367,6 +825,18 @@ class HeadlessSpawner:
367
825
  for tool in deny_tools:
368
826
  cmd.extend(["--deny-tool", tool])
369
827
 
828
+ # Track spawner start if SDK available
829
+ if sdk:
830
+ try:
831
+ sdk.track_activity(
832
+ tool="copilot_spawn_start",
833
+ summary=f"Spawning Copilot: {prompt[:80]}",
834
+ payload={"prompt_length": len(prompt)},
835
+ )
836
+ except Exception:
837
+ # Tracking failure should not break execution
838
+ pass
839
+
370
840
  try:
371
841
  result = subprocess.run(
372
842
  cmd,
@@ -398,12 +868,19 @@ class HeadlessSpawner:
398
868
  tokens = 0 # Placeholder
399
869
  break
400
870
 
871
+ # Track Copilot execution if SDK available
872
+ if sdk:
873
+ tracked_events = self._parse_and_track_copilot_events(
874
+ prompt, response, sdk
875
+ )
876
+
401
877
  return AIResult(
402
878
  success=result.returncode == 0,
403
879
  response=response,
404
880
  tokens_used=tokens,
405
881
  error=None if result.returncode == 0 else result.stderr,
406
882
  raw_output=result.stdout,
883
+ tracked_events=tracked_events,
407
884
  )
408
885
 
409
886
  except FileNotFoundError:
@@ -413,14 +890,30 @@ class HeadlessSpawner:
413
890
  tokens_used=None,
414
891
  error="Copilot CLI not found. Install from: https://docs.github.com/en/copilot/using-github-copilot/using-github-copilot-in-the-command-line",
415
892
  raw_output=None,
893
+ tracked_events=tracked_events,
416
894
  )
417
- except subprocess.TimeoutExpired:
895
+ except subprocess.TimeoutExpired as e:
418
896
  return AIResult(
419
897
  success=False,
420
898
  response="",
421
899
  tokens_used=None,
422
900
  error=f"Timed out after {timeout} seconds",
901
+ raw_output={
902
+ "partial_stdout": e.stdout.decode() if e.stdout else None,
903
+ "partial_stderr": e.stderr.decode() if e.stderr else None,
904
+ }
905
+ if e.stdout or e.stderr
906
+ else None,
907
+ tracked_events=tracked_events,
908
+ )
909
+ except Exception as e:
910
+ return AIResult(
911
+ success=False,
912
+ response="",
913
+ tokens_used=None,
914
+ error=f"Unexpected error: {type(e).__name__}: {e}",
423
915
  raw_output=None,
916
+ tracked_events=tracked_events,
424
917
  )
425
918
 
426
919
  def spawn_claude(
@@ -540,19 +1033,24 @@ class HeadlessSpawner:
540
1033
  error="Claude CLI not found. Install Claude Code from: https://claude.com/claude-code",
541
1034
  raw_output=None,
542
1035
  )
543
- except subprocess.TimeoutExpired:
1036
+ except subprocess.TimeoutExpired as e:
544
1037
  return AIResult(
545
1038
  success=False,
546
1039
  response="",
547
1040
  tokens_used=None,
548
1041
  error=f"Timed out after {timeout} seconds",
549
- raw_output=None,
1042
+ raw_output={
1043
+ "partial_stdout": e.stdout.decode() if e.stdout else None,
1044
+ "partial_stderr": e.stderr.decode() if e.stderr else None,
1045
+ }
1046
+ if e.stdout or e.stderr
1047
+ else None,
550
1048
  )
551
1049
  except Exception as e:
552
1050
  return AIResult(
553
1051
  success=False,
554
1052
  response="",
555
1053
  tokens_used=None,
556
- error=f"Unexpected error: {str(e)}",
1054
+ error=f"Unexpected error: {type(e).__name__}: {e}",
557
1055
  raw_output=None,
558
1056
  )