@@ -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+
50015048class 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:
2104521128def 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.
0 commit comments