hx-cli 0.1.0__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 (64) hide show
  1. hx/__init__.py +5 -0
  2. hx/agents/__init__.py +1 -0
  3. hx/agents/definitions.py +106 -0
  4. hx/agents/subagent.py +190 -0
  5. hx/cli.py +667 -0
  6. hx/config.py +277 -0
  7. hx/core/__init__.py +1 -0
  8. hx/core/compaction.py +245 -0
  9. hx/core/context.py +271 -0
  10. hx/core/events.py +183 -0
  11. hx/core/lateinject.py +121 -0
  12. hx/core/loop.py +537 -0
  13. hx/core/messages.py +164 -0
  14. hx/core/session.py +208 -0
  15. hx/core/usage.py +129 -0
  16. hx/frontmatter.py +80 -0
  17. hx/mcp/__init__.py +1 -0
  18. hx/mcp/client.py +319 -0
  19. hx/mcp/manager.py +265 -0
  20. hx/paths.py +90 -0
  21. hx/permissions/__init__.py +7 -0
  22. hx/permissions/engine.py +406 -0
  23. hx/permissions/parser.py +306 -0
  24. hx/permissions/sandbox.py +227 -0
  25. hx/providers/__init__.py +1 -0
  26. hx/providers/base.py +77 -0
  27. hx/providers/fake.py +87 -0
  28. hx/providers/models.py +238 -0
  29. hx/providers/openrouter.py +468 -0
  30. hx/skills/__init__.py +1 -0
  31. hx/skills/loader.py +102 -0
  32. hx/skills/runtime.py +84 -0
  33. hx/tools/__init__.py +1 -0
  34. hx/tools/base.py +97 -0
  35. hx/tools/bash.py +544 -0
  36. hx/tools/edit.py +167 -0
  37. hx/tools/glob.py +75 -0
  38. hx/tools/grep.py +165 -0
  39. hx/tools/output.py +133 -0
  40. hx/tools/read.py +142 -0
  41. hx/tools/registry.py +149 -0
  42. hx/tools/task.py +76 -0
  43. hx/tools/todo.py +149 -0
  44. hx/tools/write.py +87 -0
  45. hx/tui/__init__.py +1 -0
  46. hx/tui/app.py +487 -0
  47. hx/tui/commands.py +399 -0
  48. hx/tui/hx.tcss +197 -0
  49. hx/tui/renderers.py +570 -0
  50. hx/tui/theme.py +322 -0
  51. hx/tui/widgets/__init__.py +1 -0
  52. hx/tui/widgets/configure.py +95 -0
  53. hx/tui/widgets/diff.py +25 -0
  54. hx/tui/widgets/input.py +145 -0
  55. hx/tui/widgets/palette.py +130 -0
  56. hx/tui/widgets/permission.py +97 -0
  57. hx/tui/widgets/statusbar.py +212 -0
  58. hx/tui/widgets/todos.py +116 -0
  59. hx/tui/widgets/transcript.py +316 -0
  60. hx/tui/widgets/working.py +78 -0
  61. hx_cli-0.1.0.dist-info/METADATA +430 -0
  62. hx_cli-0.1.0.dist-info/RECORD +64 -0
  63. hx_cli-0.1.0.dist-info/WHEEL +4 -0
  64. hx_cli-0.1.0.dist-info/entry_points.txt +2 -0
hx/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """HX - a terminal coding agent."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
hx/agents/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Subagents: isolated agent loops spawned by the Task tool."""
@@ -0,0 +1,106 @@
1
+ """Agent definitions from ``.hx/agents/*.md`` and ``~/.hx/agents/*.md``.
2
+
3
+ Same frontmatter shape as skills, plus ``tools`` and ``model``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from hx.frontmatter import FrontmatterError, read, require, string_tuple
13
+ from hx.paths import project_agents_dir, user_agents_dir
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class AgentDefinition:
20
+ name: str
21
+ description: str
22
+ """Shown to the parent model in the Task tool schema, so it picks the right agent."""
23
+ system_prompt: str
24
+ tools: tuple[str, ...] = ()
25
+ """Allowlist. Empty means all tools except Task."""
26
+ model: str | None = None
27
+ """Defaults to ``settings.models.subagent_model``."""
28
+ path: Path | None = None
29
+
30
+
31
+ _EXPLORE = AgentDefinition(
32
+ name="explore",
33
+ description=(
34
+ "Read-only search across the codebase. Use when finding where something lives "
35
+ "would cost the main conversation many tool results."
36
+ ),
37
+ system_prompt=(
38
+ "You are a search agent. Locate the relevant code and report back concisely.\n\n"
39
+ "Read only what you need - excerpts, not whole files. Report file paths with "
40
+ "line numbers and a one-line note on what each contains. Do not review or "
41
+ "critique the code, and do not modify anything.\n\n"
42
+ "Your reply is the entire result: the caller cannot see your tool output."
43
+ ),
44
+ tools=("Read", "Glob", "Grep"),
45
+ )
46
+
47
+ _PLAN = AgentDefinition(
48
+ name="plan",
49
+ description="Design an implementation approach for a task, without writing code.",
50
+ system_prompt=(
51
+ "You are a software architect. Produce a concrete implementation plan.\n\n"
52
+ "Read the relevant code first. Name the files to change and what changes in "
53
+ "each, reuse what already exists rather than inventing parallel machinery, "
54
+ "and state the trade-offs you rejected. Do not modify anything.\n\n"
55
+ "Your reply is the entire result: the caller cannot see your tool output."
56
+ ),
57
+ tools=("Read", "Glob", "Grep"),
58
+ )
59
+
60
+ _GENERAL = AgentDefinition(
61
+ name="general",
62
+ description="A multi-step task that does not fit the other agents.",
63
+ system_prompt=(
64
+ "Carry out the task described and report what you did.\n\n"
65
+ "You cannot ask follow-up questions, so make reasonable decisions and state "
66
+ "the assumptions you made. Your reply is the entire result: the caller "
67
+ "cannot see your tool output."
68
+ ),
69
+ )
70
+
71
+ BUILTIN_AGENTS: tuple[AgentDefinition, ...] = (_EXPLORE, _PLAN, _GENERAL)
72
+ """Always available: read-only search, planning, and a catch-all."""
73
+
74
+
75
+ def discover(cwd: Path) -> list[AgentDefinition]:
76
+ """Builtins plus user and project definitions, project last."""
77
+ found: dict[str, AgentDefinition] = {agent.name: agent for agent in BUILTIN_AGENTS}
78
+
79
+ for directory in (user_agents_dir(), project_agents_dir(cwd)):
80
+ if not directory.is_dir():
81
+ continue
82
+ for path in sorted(directory.glob("*.md")):
83
+ try:
84
+ agent = parse_agent_file(path)
85
+ except FrontmatterError as exc:
86
+ log.warning("skipping agent: %s", exc)
87
+ continue
88
+ found[agent.name] = agent
89
+
90
+ return sorted(found.values(), key=lambda agent: agent.name)
91
+
92
+
93
+ def parse_agent_file(path: Path) -> AgentDefinition:
94
+ document = read(path)
95
+ require(document, "name", "description")
96
+ return AgentDefinition(
97
+ name=str(document.metadata["name"]).strip(),
98
+ description=str(document.metadata["description"]).strip(),
99
+ system_prompt=document.body,
100
+ tools=string_tuple(document.metadata.get("tools")),
101
+ model=(str(document.metadata["model"]).strip() if document.metadata.get("model") else None),
102
+ path=path,
103
+ )
104
+
105
+
106
+ InvalidAgent = FrontmatterError
hx/agents/subagent.py ADDED
@@ -0,0 +1,190 @@
1
+ """Subagent execution.
2
+
3
+ Each subagent gets its own :class:`~hx.core.loop.AgentLoop`, transcript, tool
4
+ allowlist and model. ``Task`` is never in a subagent's allowlist, so recursion
5
+ is impossible by construction rather than by a depth counter.
6
+
7
+ Only the final assistant text returns to the parent as the tool result - the
8
+ subagent's intermediate tool output never enters the parent's context, which is
9
+ the entire reason to spawn one.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING
17
+
18
+ from hx.agents.definitions import AgentDefinition
19
+ from hx.core.events import SubagentFinished, SubagentStarted
20
+
21
+ if TYPE_CHECKING:
22
+ from hx.config import Settings
23
+ from hx.core.events import EventBus
24
+ from hx.permissions.engine import PermissionEngine
25
+ from hx.providers.base import Provider
26
+ from hx.providers.models import ModelRegistry
27
+ from hx.tools.registry import ToolRegistry
28
+
29
+ NEVER_AVAILABLE_TO_SUBAGENTS = frozenset({"Task"})
30
+ """Recursion is prevented by omission, not by a depth counter that someone will
31
+ later raise 'just for this case'."""
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class SubagentResult:
36
+ subagent_id: str
37
+ agent_type: str
38
+ report: str
39
+ is_error: bool
40
+ turns: int
41
+ cost_usd: float
42
+ """Rolled into the parent session's ledger so the cost display stays honest."""
43
+
44
+
45
+ class SubagentRunner:
46
+ """Spawns and supervises subagents."""
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ definitions: dict[str, AgentDefinition],
52
+ provider: Provider,
53
+ tools: ToolRegistry,
54
+ permissions: PermissionEngine | None,
55
+ bus: EventBus,
56
+ settings: Settings,
57
+ models: ModelRegistry | None = None,
58
+ parent_session_id: str | None = None,
59
+ parent_usage: object | None = None,
60
+ ) -> None:
61
+ self.definitions = definitions
62
+ self.provider = provider
63
+ self.tools = tools
64
+ self.permissions = permissions
65
+ self.bus = bus
66
+ self.settings = settings
67
+ self.models = models
68
+ self.parent_session_id = parent_session_id
69
+ self.parent_usage = parent_usage
70
+ self._active: dict[str, str] = {}
71
+
72
+ async def run(self, agent_type: str, prompt: str, description: str) -> SubagentResult:
73
+ """Run one subagent to completion.
74
+
75
+ Permission prompts from a subagent surface in the parent TUI attributed
76
+ to that subagent - an approval modal with no visible origin is not an
77
+ informed approval.
78
+ """
79
+ definition = self.definitions.get(agent_type)
80
+ if definition is None:
81
+ available = ", ".join(sorted(self.definitions)) or "none"
82
+ return SubagentResult(
83
+ subagent_id="",
84
+ agent_type=agent_type,
85
+ report=f"Unknown agent type {agent_type!r}. Available: {available}",
86
+ is_error=True,
87
+ turns=0,
88
+ cost_usd=0.0,
89
+ )
90
+
91
+ subagent_id = f"sub_{uuid.uuid4().hex[:8]}"
92
+ self._active[subagent_id] = agent_type
93
+ self.bus.publish(
94
+ SubagentStarted(subagent_id=subagent_id, agent_type=agent_type, description=description)
95
+ )
96
+
97
+ try:
98
+ loop = self._build_loop(definition, subagent_id)
99
+ result = await loop.run(prompt)
100
+ report = _final_text(result) or "(the subagent returned no text)"
101
+ cost = loop.session.usage.total_cost_usd
102
+ self._roll_up_cost(loop)
103
+ return SubagentResult(
104
+ subagent_id=subagent_id,
105
+ agent_type=agent_type,
106
+ report=report,
107
+ is_error=result.error is not None,
108
+ turns=len(loop.session.usage.turns),
109
+ cost_usd=cost,
110
+ )
111
+ except Exception as exc:
112
+ return SubagentResult(
113
+ subagent_id=subagent_id,
114
+ agent_type=agent_type,
115
+ report=f"The subagent failed: {type(exc).__name__}: {exc}",
116
+ is_error=True,
117
+ turns=0,
118
+ cost_usd=0.0,
119
+ )
120
+ finally:
121
+ self._active.pop(subagent_id, None)
122
+ self.bus.publish(SubagentFinished(subagent_id=subagent_id, is_error=False))
123
+
124
+ def _build_loop(self, definition: AgentDefinition, subagent_id: str): # type: ignore[no-untyped-def]
125
+ from hx.core.compaction import Compactor
126
+ from hx.core.context import ContextBuilder
127
+ from hx.core.lateinject import InjectionRegistry
128
+ from hx.core.loop import AgentLoop
129
+ from hx.core.session import new_session
130
+
131
+ model = (
132
+ definition.model or self.settings.models.subagent_model or self.settings.models.model
133
+ )
134
+ tools = self.tools.subset(self._allowed_tools(definition))
135
+ context = ContextBuilder(
136
+ definition.system_prompt,
137
+ self.settings.cwd,
138
+ keep_recent_turns=self.settings.context.keep_recent_turns,
139
+ )
140
+ session = new_session(self.settings.cwd, model, parent_id=self.parent_session_id)
141
+
142
+ loop = AgentLoop(
143
+ provider=self.provider,
144
+ session=session,
145
+ tools=tools,
146
+ permissions=self.permissions,
147
+ context=context,
148
+ compactor=Compactor(
149
+ provider=self.provider,
150
+ model=model,
151
+ keep_recent_turns=self.settings.context.keep_recent_turns,
152
+ context=context,
153
+ ),
154
+ injections=InjectionRegistry(),
155
+ bus=self.bus,
156
+ settings=self.settings,
157
+ model_info=self.models.get_or_default(model) if self.models else None,
158
+ )
159
+ loop.origin = f"{definition.name} subagent"
160
+ return loop
161
+
162
+ def _allowed_tools(self, definition: AgentDefinition) -> set[str]:
163
+ available = set(self.tools.names()) - set(NEVER_AVAILABLE_TO_SUBAGENTS)
164
+ if not definition.tools:
165
+ return available
166
+ return {name for name in definition.tools if name in available}
167
+
168
+ def _roll_up_cost(self, loop: object) -> None:
169
+ """Fold the subagent's usage into the parent ledger.
170
+
171
+ A subagent that spends real money without moving the parent's cost
172
+ display makes that display a lie.
173
+ """
174
+ parent = self.parent_usage
175
+ if parent is None:
176
+ return
177
+ for turn in loop.session.usage.turns: # type: ignore[attr-defined]
178
+ parent.record(turn) # type: ignore[attr-defined]
179
+
180
+ def active(self) -> list[str]:
181
+ """Ids of running subagents, for the TUI progress rows."""
182
+ return sorted(self._active)
183
+
184
+
185
+ def _final_text(result: object) -> str:
186
+ messages = getattr(result, "messages", [])
187
+ for message in reversed(messages):
188
+ if message.role == "assistant" and message.text().strip():
189
+ return str(message.text().strip())
190
+ return ""