htmlgraph 0.26.5__py3-none-any.whl → 0.26.7__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.
- htmlgraph/.htmlgraph/.session-warning-state.json +1 -1
- htmlgraph/__init__.py +1 -1
- htmlgraph/api/main.py +50 -10
- htmlgraph/api/templates/dashboard-redesign.html +608 -54
- htmlgraph/api/templates/partials/activity-feed.html +21 -0
- htmlgraph/api/templates/partials/features.html +81 -12
- htmlgraph/api/templates/partials/orchestration.html +35 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +939 -0
- htmlgraph/cli/base.py +660 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +856 -0
- htmlgraph/cli/main.py +143 -0
- htmlgraph/cli/models.py +462 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +398 -0
- htmlgraph/cli/work/__init__.py +159 -0
- htmlgraph/cli/work/features.py +567 -0
- htmlgraph/cli/work/orchestration.py +675 -0
- htmlgraph/cli/work/sessions.py +465 -0
- htmlgraph/cli/work/tracks.py +485 -0
- htmlgraph/dashboard.html +6414 -634
- htmlgraph/db/schema.py +8 -3
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +20 -13
- htmlgraph/docs/README.md +2 -3
- htmlgraph/hooks/event_tracker.py +355 -26
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/orchestrator.py +137 -71
- htmlgraph/hooks/orchestrator_reflector.py +23 -0
- htmlgraph/hooks/pretooluse.py +29 -6
- htmlgraph/hooks/session_handler.py +28 -0
- htmlgraph/hooks/session_summary.py +391 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +71 -12
- htmlgraph/hooks/validator.py +192 -79
- htmlgraph/operations/__init__.py +18 -0
- htmlgraph/operations/initialization.py +596 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/orchestration/__init__.py +16 -1
- htmlgraph/orchestration/claude_launcher.py +185 -0
- htmlgraph/orchestration/command_builder.py +71 -0
- htmlgraph/orchestration/headless_spawner.py +72 -1332
- htmlgraph/orchestration/plugin_manager.py +136 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +170 -0
- htmlgraph/orchestration/spawners/codex.py +442 -0
- htmlgraph/orchestration/spawners/copilot.py +299 -0
- htmlgraph/orchestration/spawners/gemini.py +478 -0
- htmlgraph/orchestration/subprocess_runner.py +33 -0
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +620 -55
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +45 -12
- htmlgraph/transcript.py +16 -4
- htmlgraph-0.26.7.data/data/htmlgraph/dashboard.html +6592 -0
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/METADATA +1 -1
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/RECORD +68 -34
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -7256
- htmlgraph-0.26.5.data/data/htmlgraph/dashboard.html +0 -812
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Plugin management for HtmlGraph Claude Code integration.
|
|
2
|
+
|
|
3
|
+
Centralizes plugin installation, directory management, and validation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PluginManager:
|
|
18
|
+
"""Manage HtmlGraph Claude plugin installation and directories."""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def get_plugin_dir() -> Path:
|
|
22
|
+
"""Get the plugin directory path.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Path to packages/claude-plugin/.claude-plugin
|
|
26
|
+
"""
|
|
27
|
+
# Path(__file__) = .../src/python/htmlgraph/orchestration/plugin_manager.py
|
|
28
|
+
# Need to go up 5 levels to reach project root
|
|
29
|
+
return (
|
|
30
|
+
Path(__file__).parent.parent.parent.parent.parent
|
|
31
|
+
/ "packages"
|
|
32
|
+
/ "claude-plugin"
|
|
33
|
+
/ ".claude-plugin"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def install_or_update(verbose: bool = True) -> None:
|
|
38
|
+
"""Install or update HtmlGraph plugin.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
verbose: Whether to show progress messages
|
|
42
|
+
"""
|
|
43
|
+
if verbose:
|
|
44
|
+
print("\n📦 Installing/upgrading HtmlGraph plugin...\n")
|
|
45
|
+
|
|
46
|
+
# Step 1: Update marketplace
|
|
47
|
+
try:
|
|
48
|
+
if verbose:
|
|
49
|
+
print(" Updating marketplace...")
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
["claude", "plugin", "marketplace", "update", "htmlgraph"],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
if result.returncode == 0:
|
|
57
|
+
if verbose:
|
|
58
|
+
print(" ✓ Marketplace updated")
|
|
59
|
+
else:
|
|
60
|
+
# Non-blocking errors
|
|
61
|
+
if (
|
|
62
|
+
"not found" in result.stderr.lower()
|
|
63
|
+
or "no marketplace" in result.stderr.lower()
|
|
64
|
+
):
|
|
65
|
+
if verbose:
|
|
66
|
+
print(" ℹ Marketplace not configured (OK, continuing)")
|
|
67
|
+
elif verbose:
|
|
68
|
+
print(f" ⚠ Marketplace update: {result.stderr.strip()}")
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
if verbose:
|
|
71
|
+
print(" ⚠ 'claude' command not found")
|
|
72
|
+
except Exception as e:
|
|
73
|
+
if verbose:
|
|
74
|
+
print(f" ⚠ Error updating marketplace: {e}")
|
|
75
|
+
|
|
76
|
+
# Step 2: Try update, fallback to install
|
|
77
|
+
try:
|
|
78
|
+
if verbose:
|
|
79
|
+
print(" Updating plugin to latest version...")
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
["claude", "plugin", "update", "htmlgraph"],
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
check=False,
|
|
85
|
+
)
|
|
86
|
+
if result.returncode == 0:
|
|
87
|
+
if verbose:
|
|
88
|
+
print(" ✓ Plugin updated successfully")
|
|
89
|
+
else:
|
|
90
|
+
# Fallback to install
|
|
91
|
+
if (
|
|
92
|
+
"not installed" in result.stderr.lower()
|
|
93
|
+
or "not found" in result.stderr.lower()
|
|
94
|
+
):
|
|
95
|
+
if verbose:
|
|
96
|
+
print(" ℹ Plugin not yet installed, installing...")
|
|
97
|
+
install_result = subprocess.run(
|
|
98
|
+
["claude", "plugin", "install", "htmlgraph"],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
check=False,
|
|
102
|
+
)
|
|
103
|
+
if install_result.returncode == 0:
|
|
104
|
+
if verbose:
|
|
105
|
+
print(" ✓ Plugin installed successfully")
|
|
106
|
+
elif verbose:
|
|
107
|
+
print(f" ⚠ Plugin install: {install_result.stderr.strip()}")
|
|
108
|
+
elif verbose:
|
|
109
|
+
print(f" ⚠ Plugin update: {result.stderr.strip()}")
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
if verbose:
|
|
112
|
+
print(" ⚠ 'claude' command not found")
|
|
113
|
+
except Exception as e:
|
|
114
|
+
if verbose:
|
|
115
|
+
print(f" ⚠ Error updating plugin: {e}")
|
|
116
|
+
|
|
117
|
+
if verbose:
|
|
118
|
+
print("\n✓ Plugin installation complete\n")
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def validate_plugin_dir(plugin_dir: Path) -> None:
|
|
122
|
+
"""Validate that plugin directory exists, exit if not.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
plugin_dir: Path to plugin directory
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
SystemExit: If plugin directory doesn't exist
|
|
129
|
+
"""
|
|
130
|
+
if not plugin_dir.exists():
|
|
131
|
+
print(f"Error: Plugin directory not found: {plugin_dir}", file=sys.stderr)
|
|
132
|
+
print(
|
|
133
|
+
"Expected location: packages/claude-plugin/.claude-plugin",
|
|
134
|
+
file=sys.stderr,
|
|
135
|
+
)
|
|
136
|
+
sys.exit(1)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Orchestrator system prompt loading and management.
|
|
3
|
+
|
|
4
|
+
Centralizes logic for loading and combining orchestrator system prompts,
|
|
5
|
+
keeping CLI code clean and focused on invocation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import textwrap
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_orchestrator_prompt(include_dev_mode: bool = False) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Load and combine orchestrator system prompts.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
include_dev_mode: If True, append development mode guidance
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Combined system prompt text ready for --append-system-prompt
|
|
21
|
+
"""
|
|
22
|
+
package_dir = Path(__file__).parent.parent
|
|
23
|
+
|
|
24
|
+
# Load base orchestrator prompt
|
|
25
|
+
prompt_file = package_dir / "orchestrator-system-prompt-optimized.txt"
|
|
26
|
+
if prompt_file.exists():
|
|
27
|
+
base_prompt = prompt_file.read_text(encoding="utf-8")
|
|
28
|
+
else:
|
|
29
|
+
# Fallback: minimal orchestrator guidance
|
|
30
|
+
base_prompt = textwrap.dedent(
|
|
31
|
+
"""
|
|
32
|
+
You are an AI orchestrator for HtmlGraph project development.
|
|
33
|
+
|
|
34
|
+
CRITICAL DIRECTIVES:
|
|
35
|
+
1. DELEGATE to spawner skills - do not implement directly
|
|
36
|
+
2. CREATE work items before delegating (features, bugs, spikes)
|
|
37
|
+
3. USE SDK for tracking - all work must be tracked in .htmlgraph/
|
|
38
|
+
4. RESPECT dependencies - check blockers before starting
|
|
39
|
+
|
|
40
|
+
Key Rules:
|
|
41
|
+
- Exploration/Research → Skill(skill=".claude-plugin:gemini")
|
|
42
|
+
- Code implementation → Skill(skill=".claude-plugin:codex")
|
|
43
|
+
- Git/GitHub ops → Skill(skill=".claude-plugin:copilot")
|
|
44
|
+
- Strategic planning → Task() with Claude subagent
|
|
45
|
+
|
|
46
|
+
Always use:
|
|
47
|
+
from htmlgraph import SDK
|
|
48
|
+
sdk = SDK(agent='orchestrator')
|
|
49
|
+
|
|
50
|
+
See CLAUDE.md for complete orchestrator directives.
|
|
51
|
+
"""
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Load orchestration rules
|
|
55
|
+
rules_file = package_dir / "orchestration.md"
|
|
56
|
+
orchestration_rules = ""
|
|
57
|
+
if rules_file.exists():
|
|
58
|
+
orchestration_rules = rules_file.read_text(encoding="utf-8")
|
|
59
|
+
|
|
60
|
+
# Combine prompts
|
|
61
|
+
combined_prompt = base_prompt
|
|
62
|
+
if orchestration_rules:
|
|
63
|
+
combined_prompt = f"{base_prompt}\n\n---\n\n{orchestration_rules}"
|
|
64
|
+
|
|
65
|
+
# Add dev mode guidance if requested
|
|
66
|
+
if include_dev_mode:
|
|
67
|
+
dev_addendum = textwrap.dedent(
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
═══════════════════════════════════════════════════════════
|
|
71
|
+
🔧 DEVELOPMENT MODE - HtmlGraph Project
|
|
72
|
+
═══════════════════════════════════════════════════════════
|
|
73
|
+
|
|
74
|
+
CRITICAL: Hooks load htmlgraph from PyPI, NOT local source!
|
|
75
|
+
|
|
76
|
+
Development Workflow:
|
|
77
|
+
1. Make changes to src/python/htmlgraph/
|
|
78
|
+
2. Run tests: uv run pytest
|
|
79
|
+
3. Deploy to PyPI: ./scripts/deploy-all.sh X.Y.Z --no-confirm
|
|
80
|
+
4. Restart Claude Code (hooks auto-load new version from PyPI)
|
|
81
|
+
5. Verify changes work correctly
|
|
82
|
+
|
|
83
|
+
Why PyPI in Dev Mode?
|
|
84
|
+
- Hooks use: #!/usr/bin/env -S uv run --with htmlgraph
|
|
85
|
+
- Always pulls latest version from PyPI
|
|
86
|
+
- Tests in production-like environment
|
|
87
|
+
- No surprises when distributed to users
|
|
88
|
+
- Single source of truth (PyPI package)
|
|
89
|
+
|
|
90
|
+
Incremental Versioning:
|
|
91
|
+
- Use patch versions: 0.26.2 → 0.26.3 → 0.26.4
|
|
92
|
+
- No need to edit hook shebangs (always get latest)
|
|
93
|
+
- Deploy frequently for rapid iteration
|
|
94
|
+
|
|
95
|
+
Session ID Tracking (v0.26.3+):
|
|
96
|
+
- PostToolUse hooks query for most recent UserQuery session
|
|
97
|
+
- All events should share same session_id
|
|
98
|
+
- Verify: See "Development Mode" section in CLAUDE.md
|
|
99
|
+
|
|
100
|
+
Key References:
|
|
101
|
+
- Development workflow: CLAUDE.md "Development Mode" section
|
|
102
|
+
- Orchestrator patterns: /orchestrator-directives skill
|
|
103
|
+
- Code quality: /code-quality skill
|
|
104
|
+
- Deployment: /deployment-automation skill
|
|
105
|
+
|
|
106
|
+
Remember: You're dogfooding HtmlGraph!
|
|
107
|
+
- Use SDK to track your own work
|
|
108
|
+
- Delegate to spawner agents (Gemini, Codex, Copilot)
|
|
109
|
+
- Follow orchestration patterns
|
|
110
|
+
- Test in production-like environment
|
|
111
|
+
|
|
112
|
+
═══════════════════════════════════════════════════════════
|
|
113
|
+
"""
|
|
114
|
+
)
|
|
115
|
+
combined_prompt += dev_addendum
|
|
116
|
+
|
|
117
|
+
return combined_prompt
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_prompt_summary() -> dict[str, str]:
|
|
121
|
+
"""
|
|
122
|
+
Get summary of available prompt components.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Dictionary with component names and their status
|
|
126
|
+
"""
|
|
127
|
+
package_dir = Path(__file__).parent.parent
|
|
128
|
+
|
|
129
|
+
prompt_file = package_dir / "orchestrator-system-prompt-optimized.txt"
|
|
130
|
+
rules_file = package_dir / "orchestration.md"
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"base_prompt": "✓ Found" if prompt_file.exists() else "✗ Missing",
|
|
134
|
+
"orchestration_rules": "✓ Found" if rules_file.exists() else "✗ Missing",
|
|
135
|
+
"base_prompt_path": str(prompt_file),
|
|
136
|
+
"rules_path": str(rules_file),
|
|
137
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""AI spawner implementations for multi-AI orchestration."""
|
|
2
|
+
|
|
3
|
+
from .base import AIResult, BaseSpawner
|
|
4
|
+
from .claude import ClaudeSpawner
|
|
5
|
+
from .codex import CodexSpawner
|
|
6
|
+
from .copilot import CopilotSpawner
|
|
7
|
+
from .gemini import GeminiSpawner
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AIResult",
|
|
11
|
+
"BaseSpawner",
|
|
12
|
+
"ClaudeSpawner",
|
|
13
|
+
"CodexSpawner",
|
|
14
|
+
"CopilotSpawner",
|
|
15
|
+
"GeminiSpawner",
|
|
16
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Base spawner class with common functionality for all AI spawners."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from htmlgraph.orchestration.live_events import LiveEventPublisher
|
|
9
|
+
from htmlgraph.sdk import SDK
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AIResult:
|
|
14
|
+
"""Result from AI CLI execution."""
|
|
15
|
+
|
|
16
|
+
success: bool
|
|
17
|
+
response: str
|
|
18
|
+
tokens_used: int | None
|
|
19
|
+
error: str | None
|
|
20
|
+
raw_output: dict | list | str | None
|
|
21
|
+
tracked_events: list[dict] | None = None # Events tracked in HtmlGraph
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseSpawner:
|
|
25
|
+
"""
|
|
26
|
+
Base class for AI spawners with common functionality.
|
|
27
|
+
|
|
28
|
+
Provides:
|
|
29
|
+
- Live event publishing for WebSocket streaming
|
|
30
|
+
- SDK initialization with parent session support
|
|
31
|
+
- Common error handling patterns
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
"""Initialize spawner."""
|
|
36
|
+
self._live_publisher: LiveEventPublisher | None = None
|
|
37
|
+
|
|
38
|
+
def _get_live_publisher(self) -> "LiveEventPublisher | None":
|
|
39
|
+
"""
|
|
40
|
+
Get LiveEventPublisher instance for real-time WebSocket streaming.
|
|
41
|
+
|
|
42
|
+
Returns None if publisher unavailable (optional dependency).
|
|
43
|
+
"""
|
|
44
|
+
if self._live_publisher is None:
|
|
45
|
+
try:
|
|
46
|
+
from htmlgraph.orchestration.live_events import LiveEventPublisher
|
|
47
|
+
|
|
48
|
+
self._live_publisher = LiveEventPublisher()
|
|
49
|
+
except Exception:
|
|
50
|
+
# Live events are optional
|
|
51
|
+
pass
|
|
52
|
+
return self._live_publisher
|
|
53
|
+
|
|
54
|
+
def _publish_live_event(
|
|
55
|
+
self,
|
|
56
|
+
event_type: str,
|
|
57
|
+
spawner_type: str,
|
|
58
|
+
**kwargs: str | int | float | bool | None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Publish a live event for WebSocket streaming.
|
|
62
|
+
|
|
63
|
+
Silently fails if publisher unavailable (optional feature).
|
|
64
|
+
"""
|
|
65
|
+
publisher = self._get_live_publisher()
|
|
66
|
+
if publisher is None:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
parent_event_id = os.getenv("HTMLGRAPH_PARENT_EVENT")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
if event_type == "spawner_start":
|
|
73
|
+
publisher.spawner_start(
|
|
74
|
+
spawner_type=spawner_type,
|
|
75
|
+
prompt=str(kwargs.get("prompt", "")),
|
|
76
|
+
parent_event_id=parent_event_id,
|
|
77
|
+
model=str(kwargs.get("model", "")) if kwargs.get("model") else None,
|
|
78
|
+
)
|
|
79
|
+
elif event_type == "spawner_phase":
|
|
80
|
+
progress_val = kwargs.get("progress")
|
|
81
|
+
publisher.spawner_phase(
|
|
82
|
+
spawner_type=spawner_type,
|
|
83
|
+
phase=str(kwargs.get("phase", "executing")),
|
|
84
|
+
progress=int(progress_val) if progress_val is not None else None,
|
|
85
|
+
details=str(kwargs.get("details", ""))
|
|
86
|
+
if kwargs.get("details")
|
|
87
|
+
else None,
|
|
88
|
+
parent_event_id=parent_event_id,
|
|
89
|
+
)
|
|
90
|
+
elif event_type == "spawner_complete":
|
|
91
|
+
duration_val = kwargs.get("duration")
|
|
92
|
+
tokens_val = kwargs.get("tokens")
|
|
93
|
+
publisher.spawner_complete(
|
|
94
|
+
spawner_type=spawner_type,
|
|
95
|
+
success=bool(kwargs.get("success", False)),
|
|
96
|
+
duration_seconds=float(duration_val)
|
|
97
|
+
if duration_val is not None
|
|
98
|
+
else None,
|
|
99
|
+
response_preview=str(kwargs.get("response", ""))[:200]
|
|
100
|
+
if kwargs.get("response")
|
|
101
|
+
else None,
|
|
102
|
+
tokens_used=int(tokens_val) if tokens_val is not None else None,
|
|
103
|
+
error=str(kwargs.get("error", "")) if kwargs.get("error") else None,
|
|
104
|
+
parent_event_id=parent_event_id,
|
|
105
|
+
)
|
|
106
|
+
elif event_type == "spawner_tool_use":
|
|
107
|
+
publisher.spawner_tool_use(
|
|
108
|
+
spawner_type=spawner_type,
|
|
109
|
+
tool_name=str(kwargs.get("tool_name", "unknown")),
|
|
110
|
+
parent_event_id=parent_event_id,
|
|
111
|
+
)
|
|
112
|
+
elif event_type == "spawner_message":
|
|
113
|
+
publisher.spawner_message(
|
|
114
|
+
spawner_type=spawner_type,
|
|
115
|
+
message=str(kwargs.get("message", "")),
|
|
116
|
+
role=str(kwargs.get("role", "assistant")),
|
|
117
|
+
parent_event_id=parent_event_id,
|
|
118
|
+
)
|
|
119
|
+
except Exception:
|
|
120
|
+
# Live events should never break spawner execution
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def _get_sdk(self) -> "SDK | None":
|
|
124
|
+
"""
|
|
125
|
+
Get SDK instance for HtmlGraph tracking with parent session support.
|
|
126
|
+
|
|
127
|
+
Returns None if SDK unavailable.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
from htmlgraph.sdk import SDK
|
|
131
|
+
|
|
132
|
+
# Read parent session context from environment
|
|
133
|
+
parent_session = os.getenv("HTMLGRAPH_PARENT_SESSION")
|
|
134
|
+
parent_agent = os.getenv("HTMLGRAPH_PARENT_AGENT")
|
|
135
|
+
|
|
136
|
+
# Create SDK with parent session context
|
|
137
|
+
sdk = SDK(
|
|
138
|
+
agent=f"spawner-{parent_agent}" if parent_agent else "spawner",
|
|
139
|
+
parent_session=parent_session, # Pass parent session
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return sdk
|
|
143
|
+
|
|
144
|
+
except Exception:
|
|
145
|
+
# SDK unavailable or not properly initialized (optional dependency)
|
|
146
|
+
# This happens in test contexts without active sessions
|
|
147
|
+
# Don't log error to avoid noise in tests
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def _get_parent_context(self) -> tuple[str | None, int]:
|
|
151
|
+
"""
|
|
152
|
+
Get parent activity context for event tracking.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Tuple of (parent_activity_id, nesting_depth)
|
|
156
|
+
"""
|
|
157
|
+
parent_activity = os.getenv("HTMLGRAPH_PARENT_ACTIVITY")
|
|
158
|
+
nesting_depth_str = os.getenv("HTMLGRAPH_NESTING_DEPTH", "0")
|
|
159
|
+
nesting_depth = int(nesting_depth_str) if nesting_depth_str.isdigit() else 0
|
|
160
|
+
return parent_activity, nesting_depth
|
|
161
|
+
|
|
162
|
+
def _track_activity(
|
|
163
|
+
self,
|
|
164
|
+
sdk: "SDK",
|
|
165
|
+
tool: str,
|
|
166
|
+
summary: str,
|
|
167
|
+
payload: dict[str, Any] | None = None,
|
|
168
|
+
**kwargs: Any,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""
|
|
171
|
+
Track activity in HtmlGraph with parent context.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
sdk: SDK instance
|
|
175
|
+
tool: Tool name
|
|
176
|
+
summary: Activity summary
|
|
177
|
+
payload: Activity payload (will be enriched with parent context)
|
|
178
|
+
**kwargs: Additional arguments for track_activity
|
|
179
|
+
"""
|
|
180
|
+
if payload is None:
|
|
181
|
+
payload = {}
|
|
182
|
+
|
|
183
|
+
# Enrich with parent context
|
|
184
|
+
parent_activity, nesting_depth = self._get_parent_context()
|
|
185
|
+
if parent_activity:
|
|
186
|
+
payload["parent_activity"] = parent_activity
|
|
187
|
+
if nesting_depth > 0:
|
|
188
|
+
payload["nesting_depth"] = nesting_depth
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
sdk.track_activity(tool=tool, summary=summary, payload=payload, **kwargs)
|
|
192
|
+
except Exception:
|
|
193
|
+
# Tracking failure should not break execution
|
|
194
|
+
pass
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Claude spawner implementation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .base import AIResult, BaseSpawner
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClaudeSpawner(BaseSpawner):
|
|
14
|
+
"""
|
|
15
|
+
Spawner for Claude Code CLI.
|
|
16
|
+
|
|
17
|
+
NOTE: Uses same Claude Code authentication as Task() tool, but provides
|
|
18
|
+
isolated execution context. Each call creates a new session without shared
|
|
19
|
+
context. Best for independent tasks or external scripts.
|
|
20
|
+
|
|
21
|
+
For orchestration workflows with shared context, prefer Task() tool which
|
|
22
|
+
leverages prompt caching (5x cheaper for related work).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def spawn(
|
|
26
|
+
self,
|
|
27
|
+
prompt: str,
|
|
28
|
+
output_format: str = "json",
|
|
29
|
+
permission_mode: str = "bypassPermissions",
|
|
30
|
+
resume: str | None = None,
|
|
31
|
+
verbose: bool = False,
|
|
32
|
+
timeout: int = 300,
|
|
33
|
+
extra_args: list[str] | None = None,
|
|
34
|
+
) -> AIResult:
|
|
35
|
+
"""
|
|
36
|
+
Spawn Claude in headless mode.
|
|
37
|
+
|
|
38
|
+
NOTE: Uses same Claude Code authentication as Task() tool, but provides
|
|
39
|
+
isolated execution context. Each call creates a new session without shared
|
|
40
|
+
context. Best for independent tasks or external scripts.
|
|
41
|
+
|
|
42
|
+
For orchestration workflows with shared context, prefer Task() tool which
|
|
43
|
+
leverages prompt caching (5x cheaper for related work).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
prompt: Task description for Claude
|
|
47
|
+
output_format: "text" or "json" (stream-json requires --verbose)
|
|
48
|
+
permission_mode: Permission handling mode:
|
|
49
|
+
- "bypassPermissions": Auto-approve all (default)
|
|
50
|
+
- "acceptEdits": Auto-approve edits only
|
|
51
|
+
- "dontAsk": Fail on permission prompts
|
|
52
|
+
- "default": Normal interactive prompts
|
|
53
|
+
- "plan": Plan mode (no execution)
|
|
54
|
+
- "delegate": Delegation mode
|
|
55
|
+
resume: Resume from previous session (--resume). Default: None
|
|
56
|
+
verbose: Enable verbose output (--verbose). Default: False
|
|
57
|
+
timeout: Max seconds (default: 300, Claude can be slow with initialization)
|
|
58
|
+
extra_args: Additional arguments to pass to Claude CLI
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
AIResult with response or error
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
>>> spawner = ClaudeSpawner()
|
|
65
|
+
>>> result = spawner.spawn("What is 2+2?")
|
|
66
|
+
>>> if result.success:
|
|
67
|
+
... print(result.response) # "4"
|
|
68
|
+
... print(f"Cost: ${result.raw_output['total_cost_usd']}")
|
|
69
|
+
"""
|
|
70
|
+
cmd = ["claude", "-p"]
|
|
71
|
+
|
|
72
|
+
if output_format != "text":
|
|
73
|
+
cmd.extend(["--output-format", output_format])
|
|
74
|
+
|
|
75
|
+
if permission_mode:
|
|
76
|
+
cmd.extend(["--permission-mode", permission_mode])
|
|
77
|
+
|
|
78
|
+
# Add resume flag if specified
|
|
79
|
+
if resume:
|
|
80
|
+
cmd.extend(["--resume", resume])
|
|
81
|
+
|
|
82
|
+
# Add verbose flag
|
|
83
|
+
if verbose:
|
|
84
|
+
cmd.append("--verbose")
|
|
85
|
+
|
|
86
|
+
# Add extra args
|
|
87
|
+
if extra_args:
|
|
88
|
+
cmd.extend(extra_args)
|
|
89
|
+
|
|
90
|
+
# Use -- separator to ensure prompt isn't consumed by variadic args
|
|
91
|
+
cmd.append("--")
|
|
92
|
+
cmd.append(prompt)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
cmd,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
timeout=timeout,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if output_format == "json":
|
|
103
|
+
# Parse JSON output
|
|
104
|
+
try:
|
|
105
|
+
output = json.loads(result.stdout)
|
|
106
|
+
except json.JSONDecodeError as e:
|
|
107
|
+
return AIResult(
|
|
108
|
+
success=False,
|
|
109
|
+
response="",
|
|
110
|
+
tokens_used=None,
|
|
111
|
+
error=f"Failed to parse JSON output: {e}",
|
|
112
|
+
raw_output=result.stdout,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Extract result and metadata
|
|
116
|
+
usage = output.get("usage", {})
|
|
117
|
+
tokens = (
|
|
118
|
+
usage.get("input_tokens", 0)
|
|
119
|
+
+ usage.get("cache_creation_input_tokens", 0)
|
|
120
|
+
+ usage.get("cache_read_input_tokens", 0)
|
|
121
|
+
+ usage.get("output_tokens", 0)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return AIResult(
|
|
125
|
+
success=output.get("type") == "result"
|
|
126
|
+
and not output.get("is_error"),
|
|
127
|
+
response=output.get("result", ""),
|
|
128
|
+
tokens_used=tokens,
|
|
129
|
+
error=output.get("error") if output.get("is_error") else None,
|
|
130
|
+
raw_output=output,
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
# Plain text output
|
|
134
|
+
return AIResult(
|
|
135
|
+
success=result.returncode == 0,
|
|
136
|
+
response=result.stdout.strip(),
|
|
137
|
+
tokens_used=None,
|
|
138
|
+
error=None if result.returncode == 0 else result.stderr,
|
|
139
|
+
raw_output=result.stdout,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
return AIResult(
|
|
144
|
+
success=False,
|
|
145
|
+
response="",
|
|
146
|
+
tokens_used=None,
|
|
147
|
+
error="Claude CLI not found. Install Claude Code from: https://claude.com/claude-code",
|
|
148
|
+
raw_output=None,
|
|
149
|
+
)
|
|
150
|
+
except subprocess.TimeoutExpired as e:
|
|
151
|
+
return AIResult(
|
|
152
|
+
success=False,
|
|
153
|
+
response="",
|
|
154
|
+
tokens_used=None,
|
|
155
|
+
error=f"Timed out after {timeout} seconds",
|
|
156
|
+
raw_output={
|
|
157
|
+
"partial_stdout": e.stdout.decode() if e.stdout else None,
|
|
158
|
+
"partial_stderr": e.stderr.decode() if e.stderr else None,
|
|
159
|
+
}
|
|
160
|
+
if e.stdout or e.stderr
|
|
161
|
+
else None,
|
|
162
|
+
)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
return AIResult(
|
|
165
|
+
success=False,
|
|
166
|
+
response="",
|
|
167
|
+
tokens_used=None,
|
|
168
|
+
error=f"Unexpected error: {type(e).__name__}: {e}",
|
|
169
|
+
raw_output=None,
|
|
170
|
+
)
|