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, basecamp/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.
This commit is contained in:
@@ -4998,6 +4998,53 @@ class _VoiceInputMessage:
|
||||
return self.text
|
||||
|
||||
|
||||
class _SeededQueryMessage:
|
||||
"""Sentinel wrapper for a ``-q/--query`` prompt seeded into an
|
||||
interactive session.
|
||||
|
||||
When ``hermes chat -q "…"`` runs on a real TTY, the query is submitted as
|
||||
the first turn of a normal interactive session instead of the legacy
|
||||
answer-and-exit single-query mode. The prompt is arbitrary user text (an
|
||||
OS launcher, a desktop integration, a script) — it must be treated
|
||||
LITERALLY: no slash-command routing, no ``!`` shell dispatch, no
|
||||
file-drop detection. This sentinel marks the seeded first message so
|
||||
``process_loop`` skips those dispatchers for it (and only it).
|
||||
"""
|
||||
|
||||
__slots__ = ("text", "images")
|
||||
|
||||
def __init__(self, text: str, images=None):
|
||||
self.text = text or ""
|
||||
self.images = list(images or [])
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.text
|
||||
|
||||
|
||||
def _should_seed_interactive(query, image, quiet: bool, oneshot: bool) -> bool:
|
||||
"""Whether a ``-q/--image`` invocation should seed an interactive session.
|
||||
|
||||
New default (Aug 2026): on a real TTY, ``chat -q`` submits the prompt as
|
||||
the first turn of a normal interactive session (parity with other coding
|
||||
agents' seeded launches — e.g. Omarchy's prompted agent terminals).
|
||||
|
||||
The legacy answer-and-exit behavior is preserved for every automation
|
||||
surface:
|
||||
- ``--oneshot`` on the chat subcommand (explicit legacy opt-in)
|
||||
- ``-Q/--quiet`` (machine-readable single-query contract)
|
||||
- any non-TTY stdin/stdout (kanban workers, cron, pipes, A2A)
|
||||
``-z/--oneshot`` at the top level never reaches this path at all.
|
||||
"""
|
||||
if not (query or image):
|
||||
return False
|
||||
if oneshot or quiet:
|
||||
return False
|
||||
try:
|
||||
return bool(sys.stdin.isatty() and sys.stdout.isatty())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
||||
"""
|
||||
Interactive CLI for the Hermes Agent.
|
||||
@@ -5005,7 +5052,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
||||
Provides a REPL interface with rich formatting, command history,
|
||||
and tool execution capabilities.
|
||||
"""
|
||||
|
||||
|
||||
# Seeded -q handoff from main() → run() (see _should_seed_interactive):
|
||||
# run() re-creates _pending_input, so the seeded first message rides in
|
||||
# on this attribute and is enqueued after the fresh queue exists.
|
||||
_seeded_first_message: Optional["_SeededQueryMessage"] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = None,
|
||||
@@ -17741,6 +17793,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
||||
self._agent_running = False
|
||||
self._pending_input = queue.Queue() # For normal input (commands + new queries)
|
||||
self._interrupt_queue = queue.Queue() # For messages typed while agent is running
|
||||
# Seeded -q handoff: main() can't put directly into _pending_input
|
||||
# (this reinit would discard it), so the seeded first message rides
|
||||
# in on an attribute and is enqueued into the fresh queue here.
|
||||
_seed_msg = getattr(self, "_seeded_first_message", None)
|
||||
if _seed_msg is not None:
|
||||
self._seeded_first_message = None
|
||||
self._pending_input.put(_seed_msg)
|
||||
# See constructor note. Mirrored here for the run() path that skips
|
||||
# the earlier __init__ branch.
|
||||
self._last_turn_interrupted = False
|
||||
@@ -20376,6 +20435,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
||||
if is_voice_input:
|
||||
user_input = user_input.text
|
||||
|
||||
# Seeded -q prompts arrive wrapped in _SeededQueryMessage:
|
||||
# arbitrary launcher/script text that must be submitted
|
||||
# LITERALLY — skip slash routing, ! shell dispatch, and
|
||||
# file-drop detection for this one message.
|
||||
is_seeded_query = isinstance(user_input, _SeededQueryMessage)
|
||||
if is_seeded_query:
|
||||
seeded = user_input
|
||||
user_input = (
|
||||
(seeded.text, seeded.images)
|
||||
if seeded.images
|
||||
else seeded.text
|
||||
)
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
@@ -20403,8 +20475,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
||||
continue
|
||||
|
||||
# Check for commands — but detect dragged/pasted file paths first.
|
||||
# See _detect_file_drop() for details.
|
||||
_file_drop = _detect_file_drop(user_input) if isinstance(user_input, str) else None
|
||||
# See _detect_file_drop() for details. Seeded -q prompts are
|
||||
# literal text: no file-drop detection, no !/slash dispatch.
|
||||
_file_drop = (
|
||||
_detect_file_drop(user_input)
|
||||
if isinstance(user_input, str) and not is_seeded_query
|
||||
else None
|
||||
)
|
||||
if _file_drop:
|
||||
_drop_path = _file_drop["path"]
|
||||
_remainder = _file_drop["remainder"]
|
||||
@@ -20436,12 +20513,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
||||
# turn is spent. See handle_bang_shell().
|
||||
if (
|
||||
not _file_drop
|
||||
and not is_seeded_query
|
||||
and isinstance(user_input, str)
|
||||
and self.handle_bang_shell(user_input)
|
||||
):
|
||||
continue
|
||||
|
||||
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
|
||||
if (
|
||||
not _file_drop
|
||||
and not is_seeded_query
|
||||
and isinstance(user_input, str)
|
||||
and _looks_like_slash_command(user_input)
|
||||
):
|
||||
_cprint(f"\n⚙️ {user_input}")
|
||||
try:
|
||||
if not self.process_command(user_input):
|
||||
@@ -21045,6 +21128,7 @@ def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None:
|
||||
def main(
|
||||
query: str = None,
|
||||
q: str = None,
|
||||
oneshot: bool = False,
|
||||
image: str = None,
|
||||
toolsets: str = None,
|
||||
skills: str | list[str] | tuple[str, ...] = None,
|
||||
@@ -21073,8 +21157,12 @@ def main(
|
||||
Hermes Agent CLI - Interactive AI Assistant
|
||||
|
||||
Args:
|
||||
query: Single query to execute (then exit). Alias: -q
|
||||
query: Query to run. On a real TTY this seeds an interactive session
|
||||
(submitted literally as the first turn); with --oneshot/-Q or a
|
||||
non-TTY it answers and exits. Alias: -q
|
||||
q: Shorthand for --query
|
||||
oneshot: With -q: force the legacy answer-and-exit single-query mode
|
||||
even on a TTY.
|
||||
image: Optional local image path to attach to a single query
|
||||
toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal")
|
||||
skills: Comma-separated or repeated list of skills to preload for the session
|
||||
@@ -21404,6 +21492,21 @@ def main(
|
||||
|
||||
# Handle single query mode
|
||||
if query or image:
|
||||
# NEW DEFAULT (Aug 2026): on a real TTY, a -q/--image invocation
|
||||
# seeds a normal interactive session with the prompt as the first
|
||||
# turn, submitted LITERALLY (no slash/! dispatch). Legacy
|
||||
# answer-and-exit behavior is kept for --oneshot, -Q, and every
|
||||
# non-TTY invocation (kanban/cron/pipes) — see
|
||||
# _should_seed_interactive().
|
||||
if _should_seed_interactive(query, image, quiet, oneshot):
|
||||
seeded_query, seeded_images = _collect_query_images(query, image)
|
||||
logger.info(
|
||||
"Seeding interactive session with -q prompt (%d chars, %d images)",
|
||||
len(seeded_query or ""), len(seeded_images),
|
||||
)
|
||||
cli._seeded_first_message = _SeededQueryMessage(seeded_query, seeded_images)
|
||||
cli.run()
|
||||
return
|
||||
# One-shot mode: no between-turns MCP late-binding refresh, so the
|
||||
# agent must wait the full MCP cold-start bound before its first
|
||||
# (and only) tool snapshot. See #51316.
|
||||
|
||||
+21
-1
@@ -351,7 +351,12 @@ def build_top_level_parser():
|
||||
)
|
||||
_query_group = chat_parser.add_mutually_exclusive_group()
|
||||
_query_group.add_argument(
|
||||
"-q", "--query", help="Single query (non-interactive mode)"
|
||||
"-q", "--query",
|
||||
help=(
|
||||
"Query to run. On a real TTY the prompt seeds an interactive "
|
||||
"session (submitted literally as the first turn); combined with "
|
||||
"--oneshot or -Q, or on a non-TTY, it answers and exits."
|
||||
),
|
||||
)
|
||||
_query_group.add_argument(
|
||||
"--query-file",
|
||||
@@ -363,6 +368,21 @@ def build_top_level_parser():
|
||||
"verbatim. Mutually exclusive with -q."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--oneshot",
|
||||
dest="oneshot_exit",
|
||||
action="store_true",
|
||||
# Distinct dest: the top-level `-z/--oneshot PROMPT` is value-taking
|
||||
# and its dispatch sites do `if args.oneshot: _run_and_exit_oneshot(
|
||||
# args.oneshot)` — a shared boolean dest would be passed as the
|
||||
# prompt. `oneshot_exit` keeps the surfaces independent.
|
||||
default=False,
|
||||
help=(
|
||||
"With -q/--query-file: answer the query and exit (legacy "
|
||||
"single-query behavior) instead of seeding an interactive "
|
||||
"session. Implied on non-TTY stdio and by -Q/--quiet."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--image", help="Optional local image path to attach to a single query"
|
||||
)
|
||||
|
||||
@@ -3437,6 +3437,7 @@ def cmd_chat(args):
|
||||
"verbose": getattr(args, "verbose", None),
|
||||
"quiet": getattr(args, "quiet", False),
|
||||
"query": args.query,
|
||||
"oneshot": bool(getattr(args, "oneshot_exit", False)),
|
||||
"image": getattr(args, "image", None),
|
||||
"resume": getattr(args, "resume", None),
|
||||
"worktree": getattr(args, "worktree", False),
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Seeded interactive ``-q`` behavior (Aug 2026).
|
||||
|
||||
On a real TTY, ``hermes chat -q "…"`` seeds a normal interactive session with
|
||||
the prompt submitted literally as the first turn. Legacy answer-and-exit is
|
||||
preserved for ``--oneshot``, ``-Q/--quiet``, and every non-TTY invocation
|
||||
(kanban workers, cron, pipes, A2A). The seeded prompt bypasses slash-command
|
||||
routing, ``!`` shell dispatch, and file-drop detection.
|
||||
|
||||
Context: Omarchy prompted-agent launches (basecamp/omarchy#8705) needed a
|
||||
"start interactive, seeded with this prompt" mode with literal prompt
|
||||
handling, like other coding agents.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def cli_mod():
|
||||
import cli
|
||||
|
||||
return cli
|
||||
|
||||
|
||||
class TestShouldSeedInteractive:
|
||||
def _tty(self, monkeypatch, cli_mod, stdin=True, stdout=True):
|
||||
monkeypatch.setattr(
|
||||
cli_mod.sys, "stdin", types.SimpleNamespace(isatty=lambda: stdin)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli_mod.sys, "stdout", types.SimpleNamespace(isatty=lambda: stdout)
|
||||
)
|
||||
|
||||
def test_tty_query_seeds_interactive(self, monkeypatch, cli_mod):
|
||||
self._tty(monkeypatch, cli_mod)
|
||||
assert cli_mod._should_seed_interactive("hi", None, quiet=False, oneshot=False)
|
||||
|
||||
def test_image_only_also_seeds(self, monkeypatch, cli_mod):
|
||||
self._tty(monkeypatch, cli_mod)
|
||||
assert cli_mod._should_seed_interactive(
|
||||
None, "/tmp/x.png", quiet=False, oneshot=False
|
||||
)
|
||||
|
||||
def test_oneshot_flag_forces_legacy(self, monkeypatch, cli_mod):
|
||||
self._tty(monkeypatch, cli_mod)
|
||||
assert not cli_mod._should_seed_interactive(
|
||||
"hi", None, quiet=False, oneshot=True
|
||||
)
|
||||
|
||||
def test_quiet_forces_legacy(self, monkeypatch, cli_mod):
|
||||
self._tty(monkeypatch, cli_mod)
|
||||
assert not cli_mod._should_seed_interactive(
|
||||
"hi", None, quiet=True, oneshot=False
|
||||
)
|
||||
|
||||
def test_non_tty_stdin_forces_legacy(self, monkeypatch, cli_mod):
|
||||
self._tty(monkeypatch, cli_mod, stdin=False)
|
||||
assert not cli_mod._should_seed_interactive(
|
||||
"hi", None, quiet=False, oneshot=False
|
||||
)
|
||||
|
||||
def test_non_tty_stdout_forces_legacy(self, monkeypatch, cli_mod):
|
||||
self._tty(monkeypatch, cli_mod, stdout=False)
|
||||
assert not cli_mod._should_seed_interactive(
|
||||
"hi", None, quiet=False, oneshot=False
|
||||
)
|
||||
|
||||
def test_no_query_no_image_never_seeds(self, monkeypatch, cli_mod):
|
||||
self._tty(monkeypatch, cli_mod)
|
||||
assert not cli_mod._should_seed_interactive(
|
||||
None, None, quiet=False, oneshot=False
|
||||
)
|
||||
|
||||
def test_isatty_failure_forces_legacy(self, monkeypatch, cli_mod):
|
||||
def _boom():
|
||||
raise OSError("no tty")
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli_mod.sys, "stdin", types.SimpleNamespace(isatty=_boom)
|
||||
)
|
||||
assert not cli_mod._should_seed_interactive(
|
||||
"hi", None, quiet=False, oneshot=False
|
||||
)
|
||||
|
||||
|
||||
class TestSeededQueryMessage:
|
||||
def test_str_returns_text(self, cli_mod):
|
||||
msg = cli_mod._SeededQueryMessage("!echo pwned")
|
||||
assert str(msg) == "!echo pwned"
|
||||
assert msg.images == []
|
||||
|
||||
def test_images_are_copied(self, cli_mod):
|
||||
imgs = ["/tmp/a.png"]
|
||||
msg = cli_mod._SeededQueryMessage("hi", imgs)
|
||||
assert msg.images == imgs
|
||||
assert msg.images is not imgs
|
||||
|
||||
|
||||
class TestChatParserOneshotFlag:
|
||||
"""The chat subcommand's --oneshot must not collide with top-level -z."""
|
||||
|
||||
def _parse(self, argv):
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, _chat = build_top_level_parser()
|
||||
return parser.parse_args(argv)
|
||||
|
||||
def test_chat_oneshot_sets_distinct_dest(self):
|
||||
args = self._parse(["chat", "-q", "hello", "--oneshot"])
|
||||
assert args.oneshot_exit is True
|
||||
# Top-level -z prompt dest untouched — dispatch sites check
|
||||
# `args.oneshot` truthiness and would treat True as a prompt.
|
||||
assert getattr(args, "oneshot", None) in (None, False)
|
||||
|
||||
def test_chat_without_oneshot_defaults_false(self):
|
||||
args = self._parse(["chat", "-q", "hello"])
|
||||
assert args.oneshot_exit is False
|
||||
|
||||
def test_top_level_oneshot_prompt_unaffected(self):
|
||||
args = self._parse(["-z", "what is up"])
|
||||
assert args.oneshot == "what is up"
|
||||
assert getattr(args, "oneshot_exit", False) is False
|
||||
@@ -110,6 +110,45 @@ describe('submissionCore.submitPrompt — synchronous busy (queue-race fix)', ()
|
||||
})
|
||||
})
|
||||
|
||||
describe('submissionCore.submitPrompt — literal submissions (startup -q queries)', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
patchUiState({ sid: 'sess-1' })
|
||||
})
|
||||
|
||||
it('skipDetectDrop submits directly without the detect_drop round-trip', async () => {
|
||||
const { calls, gw } = makeDeferredGateway()
|
||||
|
||||
submitPrompt('!echo not-a-shell-escape', makeDeps(gw), true, undefined, { skipDetectDrop: true })
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(calls).not.toContain('input.detect_drop')
|
||||
expect(calls).toContain('prompt.submit')
|
||||
})
|
||||
|
||||
it('literal text reaches prompt.submit verbatim', async () => {
|
||||
const submitted: string[] = []
|
||||
const gw = {
|
||||
request: vi.fn((method: string, params?: { text?: string }) => {
|
||||
if (method === 'prompt.submit' && params?.text) {
|
||||
submitted.push(params.text)
|
||||
}
|
||||
|
||||
return Promise.resolve({ status: 'streaming' })
|
||||
})
|
||||
} as unknown as GatewayClient
|
||||
|
||||
submitPrompt('/model $(rm -rf ~)', makeDeps(gw), true, undefined, { skipDetectDrop: true })
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(submitted).toEqual(['/model $(rm -rf ~)'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('submissionCore.markSubmitting', () => {
|
||||
beforeEach(() => resetUiState())
|
||||
|
||||
|
||||
@@ -423,7 +423,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
const { bellOnComplete, stdout, sys } = ctx.system
|
||||
const { appendMessage, panel, setHistoryItems } = ctx.transcript
|
||||
const { setInput } = ctx.composer
|
||||
const { submitRef } = ctx.submission
|
||||
const { submitLiteralRef, submitRef } = ctx.submission
|
||||
const { setProcessing: setVoiceProcessing, setRecording: setVoiceRecording, setVoiceEnabled } = ctx.voice
|
||||
|
||||
let pendingThinkingStatus = ''
|
||||
@@ -635,7 +635,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
}
|
||||
}
|
||||
|
||||
submitRef.current(STARTUP_QUERY || 'What do you see in this image?')
|
||||
// Startup queries are arbitrary launcher/script text (Omarchy prompted
|
||||
// launches, `hermes --tui -q "…"`) — submit LITERALLY, bypassing the
|
||||
// slash/!/interpolation dispatcher, matching one-shot's semantics.
|
||||
submitLiteralRef.current(STARTUP_QUERY || 'What do you see in this image?')
|
||||
}, 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -483,6 +483,10 @@ export interface GatewayEventHandlerContext {
|
||||
setCatalog: StateSetter<null | SlashCatalog>
|
||||
}
|
||||
submission: {
|
||||
/** Submit text literally as a prompt — no slash/!/interpolation dispatch.
|
||||
* Used for `-q` startup queries, which are arbitrary launcher-provided
|
||||
* text (parity with one-shot's literal prompt handling). */
|
||||
submitLiteralRef: MutableRefObject<(value: string) => void>
|
||||
submitRef: MutableRefObject<(value: string) => void>
|
||||
}
|
||||
system: {
|
||||
|
||||
@@ -49,7 +49,8 @@ export function submitPrompt(
|
||||
text: string,
|
||||
deps: SubmitPromptDeps,
|
||||
showUserMessage = true,
|
||||
displayOverride?: string
|
||||
displayOverride?: string,
|
||||
opts: { skipDetectDrop?: boolean } = {}
|
||||
): void {
|
||||
const sid = getUiState().sid
|
||||
|
||||
@@ -107,12 +108,17 @@ export function submitPrompt(
|
||||
|
||||
// Always ask the backend whether this looks like a file drop. The backend's
|
||||
// _detect_file_drop handles paths with spaces, quotes, Windows drive letters,
|
||||
// and escaped characters correctly.
|
||||
// and escaped characters correctly. Literal submissions (startup -q queries)
|
||||
// skip it: launcher-provided text must reach the agent untouched.
|
||||
//
|
||||
// No notice is emitted for a match: an image dropped into the composer already
|
||||
// shows as an `[[ Image N ]]` token, and a matched non-image path is rewritten
|
||||
// in place. Announcing it a second time above the status bar was the old
|
||||
// out-of-band attachment UI.
|
||||
if (opts.skipDetectDrop) {
|
||||
return startSubmit(text, deps.expand(text), showUserMessage)
|
||||
}
|
||||
|
||||
deps.gw
|
||||
.request<InputDetectDropResponse>('input.detect_drop', { session_id: sid, text })
|
||||
.then(r => {
|
||||
|
||||
@@ -231,6 +231,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
|
||||
const sysRef = useRef<(text: string) => void>(() => {})
|
||||
const submitRef = useRef<(value: string) => void>(() => {})
|
||||
const submitLiteralRef = useRef<(value: string) => void>(() => {})
|
||||
const terminalHintsShownRef = useRef(new Set<string>())
|
||||
const historyItemsRef = useRef(historyItems)
|
||||
const lastUserMsgRef = useRef(lastUserMsg)
|
||||
@@ -779,7 +780,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
|
||||
sysRef.current = sys
|
||||
|
||||
const { dispatchSubmission, send, sendQueued, submit } = useSubmission({
|
||||
const { dispatchSubmission, send, sendQueued, submit, submitLiteral } = useSubmission({
|
||||
appendMessage,
|
||||
composerActions,
|
||||
composerRefs,
|
||||
@@ -791,6 +792,8 @@ export function useMainApp(gw: GatewayClient) {
|
||||
sys
|
||||
})
|
||||
|
||||
submitLiteralRef.current = submitLiteral
|
||||
|
||||
// Drain one queued message whenever the session settles (busy → false):
|
||||
// agent turn ends, interrupt, shell.exec finishes, error recovered, or the
|
||||
// session first comes up with pre-queued messages. Without this, shell.exec
|
||||
@@ -853,7 +856,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
resumeById: session.resumeById,
|
||||
setCatalog
|
||||
},
|
||||
submission: { submitRef },
|
||||
submission: { submitLiteralRef, submitRef },
|
||||
system: { bellOnComplete, stdout, sys },
|
||||
transcript: { appendMessage, panel, setHistoryItems },
|
||||
voice: {
|
||||
@@ -877,6 +880,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
setVoiceProcessing,
|
||||
setVoiceRecording,
|
||||
stdout,
|
||||
submitLiteralRef,
|
||||
submitRef,
|
||||
sys
|
||||
]
|
||||
|
||||
@@ -79,7 +79,13 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
}, [composerState.input, composerState.inputBuf])
|
||||
|
||||
const send = useCallback(
|
||||
(text: string, showUserMessage = true, displayText?: string, expandOverride?: (value: string) => string) => {
|
||||
(
|
||||
text: string,
|
||||
showUserMessage = true,
|
||||
displayText?: string,
|
||||
expandOverride?: (value: string) => string,
|
||||
submitOpts: { skipDetectDrop?: boolean } = {}
|
||||
) => {
|
||||
// Read tokens off the ref, not render state: a paste immediately followed
|
||||
// by Enter submits before React has re-rendered with the new token.
|
||||
const expand = expandOverride ?? expandTokens(composerRefs.tokensRef.current)
|
||||
@@ -95,7 +101,8 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
sys
|
||||
},
|
||||
showUserMessage,
|
||||
displayText
|
||||
displayText,
|
||||
submitOpts
|
||||
)
|
||||
},
|
||||
[appendMessage, composerActions, composerRefs, gw, setLastUserMsg, sys]
|
||||
@@ -384,7 +391,22 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
|
||||
submitRef.current = submit
|
||||
|
||||
return { dispatchSubmission, send, sendQueued, submit }
|
||||
// Literal submission: route text straight to the prompt pipeline, skipping
|
||||
// slash-command routing, `!` shell dispatch, [[token]] expansion, and
|
||||
// $(...) interpolation. Startup `-q` queries use this — they're arbitrary
|
||||
// launcher/script text, and one-shot mode already treats them literally.
|
||||
const submitLiteral = useCallback(
|
||||
(value: string) => {
|
||||
if (!value.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
send(value, true, value, v => v, { skipDetectDrop: true })
|
||||
},
|
||||
[send]
|
||||
)
|
||||
|
||||
return { dispatchSubmission, send, sendQueued, submit, submitLiteral }
|
||||
}
|
||||
|
||||
export interface UseSubmissionOptions {
|
||||
|
||||
@@ -111,8 +111,9 @@ Common options:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-q`, `--query "..."` | One-shot, non-interactive prompt. |
|
||||
| `--query-file PATH` | Read the one-shot prompt from a file (`-` = stdin). Nothing is shell-interpreted, so quotes, `$(...)`, and backticks arrive verbatim — use this for programmatic or untrusted message bodies (Bot Mode teammate DMs use it). Mutually exclusive with `-q`. |
|
||||
| `-q`, `--query "..."` | Seed the session with a prompt. On a real TTY the prompt is submitted **literally** as the first turn of a normal interactive session (it is never parsed as a slash command or `!` shell escape) and the session stays open — ideal for OS launchers and desktop integrations. With `--oneshot`, `-Q`, or non-TTY stdio it answers and exits. |
|
||||
| `--query-file PATH` | Read the query from a file (`-` = stdin). Nothing is shell-interpreted, so quotes, `$(...)`, and backticks arrive verbatim — use this for programmatic or untrusted message bodies (Bot Mode teammate DMs use it). Mutually exclusive with `-q`. |
|
||||
| `--oneshot` | With `-q`/`--query-file`: answer the query and exit (the pre-0.21 single-query behavior) instead of seeding an interactive session. Implied on non-TTY stdio and by `-Q`. |
|
||||
| `-m`, `--model <model>` | Override the model for this run. |
|
||||
| `-t`, `--toolsets <csv>` | Enable a comma-separated set of toolsets. |
|
||||
| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `upstage` (alias `solar`), `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `opencode-free` (aliases `free`, `opencode_free`; keyless), `commandcode`, `commandcode-anthropic`, `ai-gateway`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |
|
||||
@@ -135,7 +136,8 @@ Examples:
|
||||
|
||||
```bash
|
||||
hermes
|
||||
hermes chat -q "Summarize the latest PRs"
|
||||
hermes chat -q "Summarize the latest PRs" # seeds an interactive session
|
||||
hermes chat --oneshot -q "Summarize the latest PRs" # answer and exit
|
||||
hermes chat --provider openrouter --model anthropic/claude-sonnet-4.6
|
||||
hermes chat --toolsets web,terminal,skills
|
||||
hermes chat --quiet -q "Return only JSON"
|
||||
@@ -170,7 +172,7 @@ hermes -z "…" --provider openrouter --model openai/gpt-5.5
|
||||
HERMES_INFERENCE_MODEL=anthropic/claude-sonnet-4.6 hermes -z "…"
|
||||
```
|
||||
|
||||
Same agent, same tools, same skills — just strips every interactive / cosmetic layer. If you need tool output in the transcript too, use `hermes chat -q` instead; `-z` is explicitly for "I only want the final answer".
|
||||
Same agent, same tools, same skills — just strips every interactive / cosmetic layer. If you need tool output in the transcript too, use `hermes chat --oneshot -q` instead; `-z` is explicitly for "I only want the final answer".
|
||||
|
||||
#### `--usage-file` — JSON usage report for pipelines
|
||||
|
||||
|
||||
Reference in New Issue
Block a user