Skip to content

Commit 07330bb

Browse files
committed
feat(cli/tui): -q now seeds a live interactive session; prompts submit literally
On a real TTY, `hermes chat -q "…"` (and `--tui -q`) now starts a normal interactive session with the prompt submitted literally as the first turn — no slash-command routing, no '!' shell dispatch, no $(...) interpolation, no file-drop rewriting — matching how other coding agents handle seeded launches (Omarchy prompted agent terminals, omacom/omarchy#8705). Legacy answer-and-exit is preserved everywhere automation depends on it: - new `hermes chat --oneshot` flag (distinct dest from top-level -z) - -Q/--quiet machine-readable contract - any non-TTY stdio (kanban workers, cron, pipes, A2A) - top-level `hermes -z` unchanged CLI: seeded prompt rides a _SeededQueryMessage sentinel through process_loop, which skips the slash/!/file-drop dispatchers for that one message. TUI: STARTUP_QUERY submits via a new literal path (submitLiteral) that bypasses dispatchSubmission and the input.detect_drop rewrite.
1 parent 536adb3 commit 07330bb

11 files changed

Lines changed: 347 additions & 19 deletions

File tree

cli.py

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4998,14 +4998,66 @@ def __str__(self) -> str:
49984998
return self.text
49994999

50005000

5001+
class _SeededQueryMessage:
5002+
"""Sentinel wrapper for a ``-q/--query`` prompt seeded into an
5003+
interactive session.
5004+
5005+
When ``hermes chat -q "…"`` runs on a real TTY, the query is submitted as
5006+
the first turn of a normal interactive session instead of the legacy
5007+
answer-and-exit single-query mode. The prompt is arbitrary user text (an
5008+
OS launcher, a desktop integration, a script) — it must be treated
5009+
LITERALLY: no slash-command routing, no ``!`` shell dispatch, no
5010+
file-drop detection. This sentinel marks the seeded first message so
5011+
``process_loop`` skips those dispatchers for it (and only it).
5012+
"""
5013+
5014+
__slots__ = ("text", "images")
5015+
5016+
def __init__(self, text: str, images=None):
5017+
self.text = text or ""
5018+
self.images = list(images or [])
5019+
5020+
def __str__(self) -> str:
5021+
return self.text
5022+
5023+
5024+
def _should_seed_interactive(query, image, quiet: bool, oneshot: bool) -> bool:
5025+
"""Whether a ``-q/--image`` invocation should seed an interactive session.
5026+
5027+
New default (Aug 2026): on a real TTY, ``chat -q`` submits the prompt as
5028+
the first turn of a normal interactive session (parity with other coding
5029+
agents' seeded launches — e.g. Omarchy's prompted agent terminals).
5030+
5031+
The legacy answer-and-exit behavior is preserved for every automation
5032+
surface:
5033+
- ``--oneshot`` on the chat subcommand (explicit legacy opt-in)
5034+
- ``-Q/--quiet`` (machine-readable single-query contract)
5035+
- any non-TTY stdin/stdout (kanban workers, cron, pipes, A2A)
5036+
``-z/--oneshot`` at the top level never reaches this path at all.
5037+
"""
5038+
if not (query or image):
5039+
return False
5040+
if oneshot or quiet:
5041+
return False
5042+
try:
5043+
return bool(sys.stdin.isatty() and sys.stdout.isatty())
5044+
except Exception:
5045+
return False
5046+
5047+
50015048
class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
50025049
"""
50035050
Interactive CLI for the Hermes Agent.
50045051

50055052
Provides a REPL interface with rich formatting, command history,
50065053
and tool execution capabilities.
50075054
"""
5008-
5055+
5056+
# Seeded -q handoff from main() → run() (see _should_seed_interactive):
5057+
# run() re-creates _pending_input, so the seeded first message rides in
5058+
# on this attribute and is enqueued after the fresh queue exists.
5059+
_seeded_first_message: Optional["_SeededQueryMessage"] = None
5060+
50095061
def __init__(
50105062
self,
50115063
model: str = None,
@@ -17741,6 +17793,13 @@ def _prewarm_agent_runtime() -> None:
1774117793
self._agent_running = False
1774217794
self._pending_input = queue.Queue() # For normal input (commands + new queries)
1774317795
self._interrupt_queue = queue.Queue() # For messages typed while agent is running
17796+
# Seeded -q handoff: main() can't put directly into _pending_input
17797+
# (this reinit would discard it), so the seeded first message rides
17798+
# in on an attribute and is enqueued into the fresh queue here.
17799+
_seed_msg = getattr(self, "_seeded_first_message", None)
17800+
if _seed_msg is not None:
17801+
self._seeded_first_message = None
17802+
self._pending_input.put(_seed_msg)
1774417803
# See constructor note. Mirrored here for the run() path that skips
1774517804
# the earlier __init__ branch.
1774617805
self._last_turn_interrupted = False
@@ -20376,6 +20435,19 @@ def process_loop():
2037620435
if is_voice_input:
2037720436
user_input = user_input.text
2037820437

20438+
# Seeded -q prompts arrive wrapped in _SeededQueryMessage:
20439+
# arbitrary launcher/script text that must be submitted
20440+
# LITERALLY — skip slash routing, ! shell dispatch, and
20441+
# file-drop detection for this one message.
20442+
is_seeded_query = isinstance(user_input, _SeededQueryMessage)
20443+
if is_seeded_query:
20444+
seeded = user_input
20445+
user_input = (
20446+
(seeded.text, seeded.images)
20447+
if seeded.images
20448+
else seeded.text
20449+
)
20450+
2037920451
if not user_input:
2038020452
continue
2038120453

@@ -20403,8 +20475,13 @@ def process_loop():
2040320475
continue
2040420476

2040520477
# Check for commands — but detect dragged/pasted file paths first.
20406-
# See _detect_file_drop() for details.
20407-
_file_drop = _detect_file_drop(user_input) if isinstance(user_input, str) else None
20478+
# See _detect_file_drop() for details. Seeded -q prompts are
20479+
# literal text: no file-drop detection, no !/slash dispatch.
20480+
_file_drop = (
20481+
_detect_file_drop(user_input)
20482+
if isinstance(user_input, str) and not is_seeded_query
20483+
else None
20484+
)
2040820485
if _file_drop:
2040920486
_drop_path = _file_drop["path"]
2041020487
_remainder = _file_drop["remainder"]
@@ -20436,12 +20513,18 @@ def process_loop():
2043620513
# turn is spent. See handle_bang_shell().
2043720514
if (
2043820515
not _file_drop
20516+
and not is_seeded_query
2043920517
and isinstance(user_input, str)
2044020518
and self.handle_bang_shell(user_input)
2044120519
):
2044220520
continue
2044320521

20444-
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
20522+
if (
20523+
not _file_drop
20524+
and not is_seeded_query
20525+
and isinstance(user_input, str)
20526+
and _looks_like_slash_command(user_input)
20527+
):
2044520528
_cprint(f"\n⚙️ {user_input}")
2044620529
try:
2044720530
if not self.process_command(user_input):
@@ -21045,6 +21128,7 @@ def _block(reason: str) -> None:
2104521128
def main(
2104621129
query: str = None,
2104721130
q: str = None,
21131+
oneshot: bool = False,
2104821132
image: str = None,
2104921133
toolsets: str = None,
2105021134
skills: str | list[str] | tuple[str, ...] = None,
@@ -21073,8 +21157,12 @@ def main(
2107321157
Hermes Agent CLI - Interactive AI Assistant
2107421158

2107521159
Args:
21076-
query: Single query to execute (then exit). Alias: -q
21160+
query: Query to run. On a real TTY this seeds an interactive session
21161+
(submitted literally as the first turn); with --oneshot/-Q or a
21162+
non-TTY it answers and exits. Alias: -q
2107721163
q: Shorthand for --query
21164+
oneshot: With -q: force the legacy answer-and-exit single-query mode
21165+
even on a TTY.
2107821166
image: Optional local image path to attach to a single query
2107921167
toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal")
2108021168
skills: Comma-separated or repeated list of skills to preload for the session
@@ -21404,6 +21492,21 @@ def _signal_handler_q(signum, frame):
2140421492

2140521493
# Handle single query mode
2140621494
if query or image:
21495+
# NEW DEFAULT (Aug 2026): on a real TTY, a -q/--image invocation
21496+
# seeds a normal interactive session with the prompt as the first
21497+
# turn, submitted LITERALLY (no slash/! dispatch). Legacy
21498+
# answer-and-exit behavior is kept for --oneshot, -Q, and every
21499+
# non-TTY invocation (kanban/cron/pipes) — see
21500+
# _should_seed_interactive().
21501+
if _should_seed_interactive(query, image, quiet, oneshot):
21502+
seeded_query, seeded_images = _collect_query_images(query, image)
21503+
logger.info(
21504+
"Seeding interactive session with -q prompt (%d chars, %d images)",
21505+
len(seeded_query or ""), len(seeded_images),
21506+
)
21507+
cli._seeded_first_message = _SeededQueryMessage(seeded_query, seeded_images)
21508+
cli.run()
21509+
return
2140721510
# One-shot mode: no between-turns MCP late-binding refresh, so the
2140821511
# agent must wait the full MCP cold-start bound before its first
2140921512
# (and only) tool snapshot. See #51316.

hermes_cli/_parser.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,12 @@ def build_top_level_parser():
351351
)
352352
_query_group = chat_parser.add_mutually_exclusive_group()
353353
_query_group.add_argument(
354-
"-q", "--query", help="Single query (non-interactive mode)"
354+
"-q", "--query",
355+
help=(
356+
"Query to run. On a real TTY the prompt seeds an interactive "
357+
"session (submitted literally as the first turn); combined with "
358+
"--oneshot or -Q, or on a non-TTY, it answers and exits."
359+
),
355360
)
356361
_query_group.add_argument(
357362
"--query-file",
@@ -363,6 +368,21 @@ def build_top_level_parser():
363368
"verbatim. Mutually exclusive with -q."
364369
),
365370
)
371+
chat_parser.add_argument(
372+
"--oneshot",
373+
dest="oneshot_exit",
374+
action="store_true",
375+
# Distinct dest: the top-level `-z/--oneshot PROMPT` is value-taking
376+
# and its dispatch sites do `if args.oneshot: _run_and_exit_oneshot(
377+
# args.oneshot)` — a shared boolean dest would be passed as the
378+
# prompt. `oneshot_exit` keeps the surfaces independent.
379+
default=False,
380+
help=(
381+
"With -q/--query-file: answer the query and exit (legacy "
382+
"single-query behavior) instead of seeding an interactive "
383+
"session. Implied on non-TTY stdio and by -Q/--quiet."
384+
),
385+
)
366386
chat_parser.add_argument(
367387
"--image", help="Optional local image path to attach to a single query"
368388
)

hermes_cli/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3437,6 +3437,7 @@ def _skills_sync_bg() -> None:
34373437
"verbose": getattr(args, "verbose", None),
34383438
"quiet": getattr(args, "quiet", False),
34393439
"query": args.query,
3440+
"oneshot": bool(getattr(args, "oneshot_exit", False)),
34403441
"image": getattr(args, "image", None),
34413442
"resume": getattr(args, "resume", None),
34423443
"worktree": getattr(args, "worktree", False),
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Seeded interactive ``-q`` behavior (Aug 2026).
2+
3+
On a real TTY, ``hermes chat -q "…"`` seeds a normal interactive session with
4+
the prompt submitted literally as the first turn. Legacy answer-and-exit is
5+
preserved for ``--oneshot``, ``-Q/--quiet``, and every non-TTY invocation
6+
(kanban workers, cron, pipes, A2A). The seeded prompt bypasses slash-command
7+
routing, ``!`` shell dispatch, and file-drop detection.
8+
9+
Context: Omarchy prompted-agent launches (basecamp/omarchy#8705) needed a
10+
"start interactive, seeded with this prompt" mode with literal prompt
11+
handling, like other coding agents.
12+
"""
13+
14+
import sys
15+
import types
16+
17+
import pytest
18+
19+
20+
@pytest.fixture()
21+
def cli_mod():
22+
import cli
23+
24+
return cli
25+
26+
27+
class TestShouldSeedInteractive:
28+
def _tty(self, monkeypatch, cli_mod, stdin=True, stdout=True):
29+
monkeypatch.setattr(
30+
cli_mod.sys, "stdin", types.SimpleNamespace(isatty=lambda: stdin)
31+
)
32+
monkeypatch.setattr(
33+
cli_mod.sys, "stdout", types.SimpleNamespace(isatty=lambda: stdout)
34+
)
35+
36+
def test_tty_query_seeds_interactive(self, monkeypatch, cli_mod):
37+
self._tty(monkeypatch, cli_mod)
38+
assert cli_mod._should_seed_interactive("hi", None, quiet=False, oneshot=False)
39+
40+
def test_image_only_also_seeds(self, monkeypatch, cli_mod):
41+
self._tty(monkeypatch, cli_mod)
42+
assert cli_mod._should_seed_interactive(
43+
None, "/tmp/x.png", quiet=False, oneshot=False
44+
)
45+
46+
def test_oneshot_flag_forces_legacy(self, monkeypatch, cli_mod):
47+
self._tty(monkeypatch, cli_mod)
48+
assert not cli_mod._should_seed_interactive(
49+
"hi", None, quiet=False, oneshot=True
50+
)
51+
52+
def test_quiet_forces_legacy(self, monkeypatch, cli_mod):
53+
self._tty(monkeypatch, cli_mod)
54+
assert not cli_mod._should_seed_interactive(
55+
"hi", None, quiet=True, oneshot=False
56+
)
57+
58+
def test_non_tty_stdin_forces_legacy(self, monkeypatch, cli_mod):
59+
self._tty(monkeypatch, cli_mod, stdin=False)
60+
assert not cli_mod._should_seed_interactive(
61+
"hi", None, quiet=False, oneshot=False
62+
)
63+
64+
def test_non_tty_stdout_forces_legacy(self, monkeypatch, cli_mod):
65+
self._tty(monkeypatch, cli_mod, stdout=False)
66+
assert not cli_mod._should_seed_interactive(
67+
"hi", None, quiet=False, oneshot=False
68+
)
69+
70+
def test_no_query_no_image_never_seeds(self, monkeypatch, cli_mod):
71+
self._tty(monkeypatch, cli_mod)
72+
assert not cli_mod._should_seed_interactive(
73+
None, None, quiet=False, oneshot=False
74+
)
75+
76+
def test_isatty_failure_forces_legacy(self, monkeypatch, cli_mod):
77+
def _boom():
78+
raise OSError("no tty")
79+
80+
monkeypatch.setattr(
81+
cli_mod.sys, "stdin", types.SimpleNamespace(isatty=_boom)
82+
)
83+
assert not cli_mod._should_seed_interactive(
84+
"hi", None, quiet=False, oneshot=False
85+
)
86+
87+
88+
class TestSeededQueryMessage:
89+
def test_str_returns_text(self, cli_mod):
90+
msg = cli_mod._SeededQueryMessage("!echo pwned")
91+
assert str(msg) == "!echo pwned"
92+
assert msg.images == []
93+
94+
def test_images_are_copied(self, cli_mod):
95+
imgs = ["/tmp/a.png"]
96+
msg = cli_mod._SeededQueryMessage("hi", imgs)
97+
assert msg.images == imgs
98+
assert msg.images is not imgs
99+
100+
101+
class TestChatParserOneshotFlag:
102+
"""The chat subcommand's --oneshot must not collide with top-level -z."""
103+
104+
def _parse(self, argv):
105+
from hermes_cli._parser import build_top_level_parser
106+
107+
parser, _subparsers, _chat = build_top_level_parser()
108+
return parser.parse_args(argv)
109+
110+
def test_chat_oneshot_sets_distinct_dest(self):
111+
args = self._parse(["chat", "-q", "hello", "--oneshot"])
112+
assert args.oneshot_exit is True
113+
# Top-level -z prompt dest untouched — dispatch sites check
114+
# `args.oneshot` truthiness and would treat True as a prompt.
115+
assert getattr(args, "oneshot", None) in (None, False)
116+
117+
def test_chat_without_oneshot_defaults_false(self):
118+
args = self._parse(["chat", "-q", "hello"])
119+
assert args.oneshot_exit is False
120+
121+
def test_top_level_oneshot_prompt_unaffected(self):
122+
args = self._parse(["-z", "what is up"])
123+
assert args.oneshot == "what is up"
124+
assert getattr(args, "oneshot_exit", False) is False

ui-tui/src/__tests__/submissionCore.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,45 @@ describe('submissionCore.submitPrompt — synchronous busy (queue-race fix)', ()
110110
})
111111
})
112112

113+
describe('submissionCore.submitPrompt — literal submissions (startup -q queries)', () => {
114+
beforeEach(() => {
115+
resetUiState()
116+
patchUiState({ sid: 'sess-1' })
117+
})
118+
119+
it('skipDetectDrop submits directly without the detect_drop round-trip', async () => {
120+
const { calls, gw } = makeDeferredGateway()
121+
122+
submitPrompt('!echo not-a-shell-escape', makeDeps(gw), true, undefined, { skipDetectDrop: true })
123+
124+
await Promise.resolve()
125+
await Promise.resolve()
126+
127+
expect(calls).not.toContain('input.detect_drop')
128+
expect(calls).toContain('prompt.submit')
129+
})
130+
131+
it('literal text reaches prompt.submit verbatim', async () => {
132+
const submitted: string[] = []
133+
const gw = {
134+
request: vi.fn((method: string, params?: { text?: string }) => {
135+
if (method === 'prompt.submit' && params?.text) {
136+
submitted.push(params.text)
137+
}
138+
139+
return Promise.resolve({ status: 'streaming' })
140+
})
141+
} as unknown as GatewayClient
142+
143+
submitPrompt('/model $(rm -rf ~)', makeDeps(gw), true, undefined, { skipDetectDrop: true })
144+
145+
await Promise.resolve()
146+
await Promise.resolve()
147+
148+
expect(submitted).toEqual(['/model $(rm -rf ~)'])
149+
})
150+
})
151+
113152
describe('submissionCore.markSubmitting', () => {
114153
beforeEach(() => resetUiState())
115154

0 commit comments

Comments
 (0)