Skip to content

Commit 52aeb99

Browse files
win4rclaude
andauthored
feat: add Hermes Agent as native spawn target (#63)
* feat: add Hermes Agent as native spawn target Add first-class support for Hermes Agent CLI in the spawn adapter system, following the same patterns used by OpenClaw, Claude, Codex, and other agents. Spawn adapters (adapters.py, tmux_backend.py, subprocess_backend.py): - is_hermes_command() detection in command_validation.py and adapters.py - Hermes included in is_interactive_cli() across all modules - Auto-insert 'chat' subcommand when bare 'hermes' is passed - Prompt via -q flag, session isolation via --continue - Model forwarding via -m in tmux_backend - --yolo for skip_permissions across all three backends - None-guard on agent_name to prevent literal "None" session keys Tests (test_adapters.py): - is_hermes_command detection (bare name, full path, negatives) - is_interactive_cli includes hermes - 7 new tests: yolo, chat insertion, no-duplicate-chat, prompt via -q, --continue session, no-continue-without-name, yolo-preserved-with-chat 470 tests pass, 0 failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace invalid --continue flag with --source tool for Hermes spawns Discovered during end-to-end testing: Hermes --continue only RESUMES existing sessions and errors out with "No session found" when given a fresh name. Fresh spawns must let Hermes auto-generate the session ID. Changes: - Remove automatic --continue flag from Hermes spawn commands - Add --source tool so clawteam-spawned Hermes sessions don't pollute the user's session list (default --source cli) - Update tests to verify --source tool is set and --continue is absent Verified end-to-end: hermes chat --yolo --source tool -q "..." responds correctly and exits cleanly with an auto-generated session ID. 470 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add Hermes Agent to README supported agents List Hermes in the agents badge, the prose agent list, and the Supported Agents table. Spawn command: clawteam spawn hermes --team ... Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address codex review findings for Hermes adapter Codex independent review caught 3 real issues: [P1] subprocess_backend missing Hermes command block - Before: Hermes fell through to generic 'hermes -p <prompt>' on Windows or explicit subprocess backend. Hermes -p means --profile, not prompt. - After: Added Hermes block with chat insertion, --source tool, -m model, and -q prompt. Mirrors tmux_backend behavior. [P2] Blind 'chat' insertion clobbered global options - Before: insert(1, 'chat') on any hermes command, rewriting 'hermes --profile foo' into 'hermes chat --profile foo' and breaking argv order. - After: Only insert chat when user's original command is bare 'hermes' (len(normalized_command) == 1). Respect user-supplied global options and alternate subcommands (sessions, model, setup, etc.). [P3] No backend-level Hermes tests - Added test_tmux_backend_hermes_chat_source_and_prompt - Added test_subprocess_backend_hermes_chat_source_and_prompt - Added test_hermes_preserves_global_options (regression for --profile) - Added test_hermes_preserves_alternate_subcommand [P3] README overstated support - Clarified Hermes status: "Full support (tmux + subprocess)" 474 tests pass (up from 470, +4 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 69be591 commit 52aeb99

7 files changed

Lines changed: 226 additions & 9 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
<p align="center">
2828
<img src="https://img.shields.io/badge/python-≥3.10-blue?logo=python&logoColor=white" alt="Python">
29-
<img src="https://img.shields.io/badge/agents-OpenClaw_%7C_Claude_Code_%7C_Codex_%7C_nanobot-blueviolet" alt="Agents">
29+
<img src="https://img.shields.io/badge/agents-OpenClaw_%7C_Claude_Code_%7C_Codex_%7C_Hermes_%7C_nanobot-blueviolet" alt="Agents">
3030
<img src="https://img.shields.io/badge/transport-File_%7C_ZeroMQ_P2P-orange" alt="Transport">
3131
<img src="https://img.shields.io/badge/version-0.3.0-teal" alt="Version">
3232
</p>
@@ -35,7 +35,7 @@
3535
3636
You set the goal. The agent swarm handles the rest — spawning workers, splitting tasks, coordinating, and merging results.
3737

38-
Works with [OpenClaw](https://openclaw.ai) (default), [Claude Code](https://claude.ai/claude-code), [Codex](https://openai.com/codex), [nanobot](https://github.com/HKUDS/nanobot), [Cursor](https://cursor.com), and any CLI agent.
38+
Works with [OpenClaw](https://openclaw.ai) (default), [Claude Code](https://claude.ai/claude-code), [Codex](https://openai.com/codex), [Hermes Agent](https://github.com/NousResearch/hermes-agent), [nanobot](https://github.com/HKUDS/nanobot), [Cursor](https://cursor.com), and any CLI agent.
3939

4040
## Platform Support
4141

@@ -141,6 +141,7 @@ clawteam board attach my-team # Linux/macOS/WSL with tmux
141141
| [Claude Code](https://claude.ai/claude-code) | `clawteam spawn claude --team ...` | Full support |
142142
| [Codex](https://openai.com/codex) | `clawteam spawn codex --team ...` | Full support |
143143
| [nanobot](https://github.com/HKUDS/nanobot) | `clawteam spawn nanobot --team ...` | Full support |
144+
| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | `clawteam spawn hermes --team ...` | Full support (tmux + subprocess) |
144145
| [Cursor](https://cursor.com) | `clawteam spawn subprocess cursor --team ...` | Experimental |
145146
| Custom scripts | `clawteam spawn subprocess python --team ...` | Full support |
146147

clawteam/spawn/adapters.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class PreparedCommand:
1818

1919

2020
class NativeCliAdapter:
21-
"""Adapter for direct CLI runtimes such as claude, codex, gemini, kimi, nanobot, qwen, opencode."""
21+
"""Adapter for direct CLI runtimes such as claude, codex, gemini, hermes, kimi, nanobot, qwen, opencode."""
2222

2323
def prepare_command(
2424
self,
@@ -43,10 +43,27 @@ def prepare_command(
4343
is_gemini_command(normalized_command)
4444
or is_kimi_command(normalized_command)
4545
or is_opencode_command(normalized_command)
46+
or is_hermes_command(normalized_command)
4647
):
4748
final_command.append("--yolo")
4849

49-
if is_kimi_command(normalized_command):
50+
if is_hermes_command(normalized_command):
51+
# Hermes: tag as tool-sourced so clawteam spawns don't pollute the
52+
# user's session list, pass prompt via -q. Insert 'chat' subcommand
53+
# only when the user's original command is bare `hermes` (don't clobber
54+
# user-supplied global options or alternate subcommands).
55+
# Check normalized_command, not final_command, since skip_permissions
56+
# may have already appended --yolo.
57+
# Do NOT pass --continue -- Hermes --continue resumes EXISTING sessions
58+
# only; fresh spawns auto-generate a session ID.
59+
if len(normalized_command) == 1:
60+
# Insert chat at position 1 (before any --yolo already appended).
61+
final_command.insert(1, "chat")
62+
if "--source" not in final_command:
63+
final_command.extend(["--source", "tool"])
64+
if prompt:
65+
final_command.extend(["-q", prompt])
66+
elif is_kimi_command(normalized_command):
5067
if cwd and not command_has_workspace_arg(normalized_command):
5168
final_command.extend(["-w", cwd])
5269
if prompt:
@@ -158,6 +175,11 @@ def is_openclaw_command(command: list[str]) -> bool:
158175
return command_basename(command) == "openclaw"
159176

160177

178+
def is_hermes_command(command: list[str]) -> bool:
179+
"""Check if the command is a Hermes Agent CLI invocation."""
180+
return command_basename(command) == "hermes"
181+
182+
161183
def is_interactive_cli(command: list[str]) -> bool:
162184
"""Check if the command is a known interactive AI coding CLI."""
163185
return (
@@ -169,6 +191,7 @@ def is_interactive_cli(command: list[str]) -> bool:
169191
or is_qwen_command(command)
170192
or is_opencode_command(command)
171193
or is_openclaw_command(command)
194+
or is_hermes_command(command)
172195
)
173196

174197

clawteam/spawn/command_validation.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ def is_opencode_command(command: list[str]) -> bool:
102102
return _cmd_basename(command) == "opencode"
103103

104104

105+
def is_hermes_command(command: list[str]) -> bool:
106+
"""Check if the command is a Hermes Agent CLI invocation."""
107+
return _cmd_basename(command) == "hermes"
108+
109+
105110
def is_interactive_cli(command: list[str]) -> bool:
106111
"""Check if the command is an interactive AI CLI."""
107112
return (
@@ -113,6 +118,7 @@ def is_interactive_cli(command: list[str]) -> bool:
113118
or is_kimi_command(command)
114119
or is_qwen_command(command)
115120
or is_opencode_command(command)
121+
or is_hermes_command(command)
116122
)
117123

118124

clawteam/spawn/subprocess_backend.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
is_claude_command,
1818
is_codex_command,
1919
is_gemini_command,
20+
is_hermes_command,
2021
is_kimi_command,
2122
is_nanobot_command,
2223
is_openclaw_command,
@@ -95,15 +96,30 @@ def spawn(
9596
final_command.append("--dangerously-skip-permissions")
9697
elif is_codex_command(normalized_command):
9798
final_command.append("--dangerously-bypass-approvals-and-sandbox")
98-
elif is_gemini_command(normalized_command) or is_kimi_command(normalized_command) or is_opencode_command(normalized_command):
99+
elif is_gemini_command(normalized_command) or is_kimi_command(normalized_command) or is_opencode_command(normalized_command) or is_hermes_command(normalized_command):
99100
final_command.append("--yolo")
100101
# Claude Code: pass --model if specified
101102
# Pass --model if specified (claude, openclaw)
102103
if model and is_claude_command(normalized_command):
103104
final_command.extend(["--model", model])
104105
if model and is_openclaw_command(normalized_command):
105106
final_command.extend(["--model", model])
106-
if is_kimi_command(normalized_command):
107+
# Hermes Agent: insert 'chat' only when the user's original command is
108+
# bare `hermes` (don't clobber user-supplied global options or subcommands).
109+
# Check normalized_command, not final_command, since skip_permissions
110+
# may have already appended --yolo.
111+
# Tag with --source tool so clawteam spawns don't pollute user session list.
112+
# Pass prompt via -q (Hermes -p is --profile, not prompt).
113+
if is_hermes_command(normalized_command):
114+
if len(normalized_command) == 1:
115+
final_command.insert(1, "chat")
116+
if "--source" not in final_command:
117+
final_command.extend(["--source", "tool"])
118+
if model:
119+
final_command.extend(["-m", model])
120+
if prompt:
121+
final_command.extend(["-q", prompt])
122+
elif is_kimi_command(normalized_command):
107123
if cwd and not command_has_workspace_arg(normalized_command):
108124
final_command.extend(["-w", cwd])
109125
if prompt:

clawteam/spawn/tmux_backend.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
is_claude_command,
2626
is_codex_command,
2727
is_gemini_command,
28+
is_hermes_command,
2829
is_kimi_command,
2930
is_nanobot_command,
3031
is_openclaw_command,
@@ -176,7 +177,7 @@ def spawn(
176177
final_command.append("--dangerously-skip-permissions")
177178
elif is_codex_command(normalized_command):
178179
final_command.append("--dangerously-bypass-approvals-and-sandbox")
179-
elif is_gemini_command(normalized_command) or is_kimi_command(normalized_command) or is_opencode_command(normalized_command):
180+
elif is_gemini_command(normalized_command) or is_kimi_command(normalized_command) or is_opencode_command(normalized_command) or is_hermes_command(normalized_command):
180181
final_command.append("--yolo")
181182

182183
# Claude Code: pass --model if specified
@@ -210,6 +211,25 @@ def spawn(
210211
if prompt:
211212
final_command.extend(["--message", prompt])
212213

214+
# Hermes Agent: tag as tool-sourced so clawteam spawns don't pollute the
215+
# user's session list, pass prompt via -q. Insert 'chat' subcommand
216+
# only when the user's original command is bare `hermes` (don't clobber
217+
# user-supplied global options or alternate subcommands).
218+
# Check normalized_command, not final_command, since skip_permissions
219+
# may have already appended --yolo.
220+
# Do NOT pass --continue -- Hermes --continue resumes EXISTING sessions
221+
# only; fresh spawns auto-generate a session ID.
222+
if is_hermes_command(normalized_command):
223+
if len(normalized_command) == 1:
224+
# Insert chat at position 1 (before any --yolo already appended).
225+
final_command.insert(1, "chat")
226+
if "--source" not in final_command:
227+
final_command.extend(["--source", "tool"])
228+
if model:
229+
final_command.extend(["-m", model])
230+
if prompt:
231+
final_command.extend(["-q", prompt])
232+
213233
if is_kimi_command(normalized_command):
214234
if cwd and not command_has_workspace_arg(normalized_command):
215235
final_command.extend(["-w", cwd])
@@ -300,7 +320,7 @@ def spawn(
300320
fallback_delay=cfg.spawn_prompt_delay,
301321
)
302322
_inject_prompt_via_buffer(target, agent_name, prompt)
303-
elif prompt and not is_codex_command(normalized_command) and not is_openclaw_command(normalized_command) and not is_nanobot_command(normalized_command) and not is_gemini_command(normalized_command) and not is_kimi_command(normalized_command) and not is_qwen_command(normalized_command) and not is_opencode_command(normalized_command):
323+
elif prompt and not is_codex_command(normalized_command) and not is_openclaw_command(normalized_command) and not is_hermes_command(normalized_command) and not is_nanobot_command(normalized_command) and not is_gemini_command(normalized_command) and not is_kimi_command(normalized_command) and not is_qwen_command(normalized_command) and not is_opencode_command(normalized_command):
304324
# Generic command: append prompt via send-keys
305325
_wait_for_tui_ready(
306326
target,

tests/test_adapters.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from clawteam.spawn.adapters import (
66
NativeCliAdapter,
77
command_basename,
8+
is_hermes_command,
89
is_interactive_cli,
910
is_opencode_command,
1011
is_qwen_command,
@@ -27,8 +28,14 @@ def test_is_opencode_command(self):
2728
assert not is_opencode_command(["openai"])
2829
assert not is_opencode_command([])
2930

31+
def test_is_hermes_command(self):
32+
assert is_hermes_command(["hermes"])
33+
assert is_hermes_command(["/usr/local/bin/hermes"])
34+
assert not is_hermes_command(["claude"])
35+
assert not is_hermes_command([])
36+
3037
def test_is_interactive_cli_covers_all_known(self):
31-
for cmd in ["claude", "codex", "nanobot", "gemini", "kimi", "qwen", "opencode"]:
38+
for cmd in ["claude", "codex", "nanobot", "gemini", "hermes", "kimi", "qwen", "opencode"]:
3239
assert is_interactive_cli([cmd]), f"{cmd} should be interactive"
3340

3441
def test_is_interactive_cli_rejects_unknown(self):
@@ -112,3 +119,73 @@ def test_codex_exec_remains_noninteractive(self):
112119
)
113120
assert result.post_launch_prompt is None
114121
assert "hello" in result.final_command
122+
123+
124+
class TestHermesCommandPreparation:
125+
"""Hermes Agent: chat subcommand insertion, --source tool tag, -q prompt, --yolo."""
126+
127+
adapter = NativeCliAdapter()
128+
129+
def test_hermes_gets_yolo(self):
130+
result = self.adapter.prepare_command(
131+
["hermes"], skip_permissions=True, agent_name="w1",
132+
)
133+
assert "--yolo" in result.final_command
134+
assert "chat" in result.final_command
135+
136+
def test_hermes_chat_subcommand_inserted(self):
137+
result = self.adapter.prepare_command(["hermes"], agent_name="w1")
138+
assert result.final_command[1] == "chat"
139+
140+
def test_hermes_no_duplicate_chat(self):
141+
result = self.adapter.prepare_command(["hermes", "chat"], agent_name="w1")
142+
assert result.final_command.count("chat") == 1
143+
144+
def test_hermes_preserves_global_options(self):
145+
# If user passes hermes with global options (e.g., --profile), we must
146+
# NOT insert 'chat' and break the argv order. Hermes CLI shape is
147+
# `hermes [global-options] <command>`.
148+
result = self.adapter.prepare_command(
149+
["hermes", "--profile", "foo"], agent_name="w1",
150+
)
151+
# chat should NOT be injected when the user passed global options
152+
assert "chat" not in result.final_command
153+
# source tag should still apply
154+
assert "--source" in result.final_command
155+
156+
def test_hermes_preserves_alternate_subcommand(self):
157+
# If user passes a non-chat subcommand (e.g., `hermes sessions`),
158+
# we must not rewrite it as `hermes chat sessions`.
159+
result = self.adapter.prepare_command(["hermes", "sessions"], agent_name="w1")
160+
assert result.final_command[1] == "sessions"
161+
assert "chat" not in result.final_command
162+
163+
def test_hermes_prompt_via_q_flag(self):
164+
result = self.adapter.prepare_command(
165+
["hermes"], prompt="do work", agent_name="w1",
166+
)
167+
assert "-q" in result.final_command
168+
assert "do work" in result.final_command
169+
assert result.post_launch_prompt is None
170+
171+
def test_hermes_tagged_as_tool_source(self):
172+
# Hermes spawns from clawteam use --source tool so they don't
173+
# pollute the user's session list (which defaults to cli)
174+
result = self.adapter.prepare_command(["hermes"], agent_name="w1")
175+
assert "--source" in result.final_command
176+
assert "tool" in result.final_command
177+
178+
def test_hermes_no_continue_flag(self):
179+
# Hermes --continue resumes an existing session; clawteam spawns
180+
# are fresh, so we must not pass --continue
181+
result = self.adapter.prepare_command(["hermes"], agent_name="w1")
182+
assert "--continue" not in result.final_command
183+
184+
def test_hermes_yolo_preserved_with_chat(self):
185+
result = self.adapter.prepare_command(
186+
["hermes"], skip_permissions=True, agent_name="w1",
187+
)
188+
assert "--yolo" in result.final_command
189+
assert "chat" in result.final_command
190+
chat_idx = result.final_command.index("chat")
191+
assert chat_idx == 1 # chat at position 1

tests/test_spawn_backends.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,80 @@ def fake_popen(cmd, **kwargs):
12571257
assert captured["cmd"][-4:] == ["opencode", "--yolo", "-p", "fix the bug"]
12581258

12591259

1260+
def test_tmux_backend_hermes_chat_source_and_prompt(monkeypatch, tmp_path):
1261+
run_calls = _make_tmux_spawn_harness(monkeypatch, tmp_path, "hermes")
1262+
1263+
backend = TmuxBackend()
1264+
result = backend.spawn(
1265+
command=["hermes"],
1266+
agent_name="researcher",
1267+
agent_id="agent-h",
1268+
agent_type="general-purpose",
1269+
team_name="demo-team",
1270+
prompt="do research",
1271+
cwd="/tmp/demo",
1272+
skip_permissions=True,
1273+
)
1274+
1275+
assert "spawned" in result
1276+
new_session = next(c for c in run_calls if c[:3] == ["tmux", "new-session", "-d"])
1277+
full_cmd = new_session[-1]
1278+
# Verify: chat subcommand inserted, --yolo (skip_permissions), --source tool,
1279+
# -q prompt, and NO --continue (which only resumes existing sessions)
1280+
assert "hermes chat" in full_cmd
1281+
assert "--yolo" in full_cmd
1282+
assert "--source tool" in full_cmd
1283+
assert "-q 'do research'" in full_cmd
1284+
assert "--continue" not in full_cmd
1285+
1286+
1287+
def test_subprocess_backend_hermes_chat_source_and_prompt(monkeypatch, tmp_path):
1288+
monkeypatch.setenv("PATH", "/usr/bin:/bin")
1289+
clawteam_bin = tmp_path / "venv" / "bin" / "clawteam"
1290+
clawteam_bin.parent.mkdir(parents=True)
1291+
clawteam_bin.write_text("#!/bin/sh\n")
1292+
monkeypatch.setattr(sys, "argv", [str(clawteam_bin)])
1293+
1294+
captured: dict[str, object] = {}
1295+
1296+
def fake_popen(cmd, **kwargs):
1297+
captured["cmd"] = cmd
1298+
return DummyProcess()
1299+
1300+
monkeypatch.setattr(
1301+
"clawteam.spawn.command_validation.shutil.which",
1302+
lambda name, path=None: "/usr/bin/hermes" if name == "hermes" else None,
1303+
)
1304+
monkeypatch.setattr("clawteam.spawn.subprocess_backend.subprocess.Popen", fake_popen)
1305+
monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None)
1306+
1307+
backend = SubprocessBackend()
1308+
backend.spawn(
1309+
command=["hermes"],
1310+
agent_name="researcher",
1311+
agent_id="agent-h",
1312+
agent_type="general-purpose",
1313+
team_name="demo-team",
1314+
prompt="do research",
1315+
cwd="/tmp/demo",
1316+
skip_permissions=True,
1317+
)
1318+
1319+
cmd = captured["cmd"]
1320+
# Find the start of the agent command within the subprocess wrapper args
1321+
hermes_idx = cmd.index("hermes")
1322+
agent_cmd = cmd[hermes_idx:]
1323+
# Verify: chat subcommand, --yolo, --source tool, -q prompt (not -p!)
1324+
assert agent_cmd[:2] == ["hermes", "chat"]
1325+
assert "--yolo" in agent_cmd
1326+
assert "--source" in agent_cmd
1327+
assert "tool" in agent_cmd
1328+
assert "-q" in agent_cmd
1329+
assert "do research" in agent_cmd
1330+
# Must NOT use -p (that's --profile in Hermes)
1331+
assert "-p" not in agent_cmd
1332+
1333+
12601334
# --- Gateway token propagation (issue #51) ---
12611335

12621336

0 commit comments

Comments
 (0)