diff --git a/cli.py b/cli.py index 2600a5845c..7d5aec9f5b 100644 --- a/cli.py +++ b/cli.py @@ -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. diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 638c9104fe..57b2616fc4 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -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" ) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 467ab2637a..a45a493657 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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), diff --git a/tests/hermes_cli/test_seeded_interactive_query.py b/tests/hermes_cli/test_seeded_interactive_query.py new file mode 100644 index 0000000000..c0613cbf19 --- /dev/null +++ b/tests/hermes_cli/test_seeded_interactive_query.py @@ -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 diff --git a/ui-tui/src/__tests__/submissionCore.test.ts b/ui-tui/src/__tests__/submissionCore.test.ts index 23b83e8746..de95add4da 100644 --- a/ui-tui/src/__tests__/submissionCore.test.ts +++ b/ui-tui/src/__tests__/submissionCore.test.ts @@ -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()) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 78fb1ed5c3..86a988ed40 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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) } diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 1a4cfbc565..69689b74a0 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -483,6 +483,10 @@ export interface GatewayEventHandlerContext { setCatalog: StateSetter } 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: { diff --git a/ui-tui/src/app/submissionCore.ts b/ui-tui/src/app/submissionCore.ts index aa6cd98db6..16406e0143 100644 --- a/ui-tui/src/app/submissionCore.ts +++ b/ui-tui/src/app/submissionCore.ts @@ -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('input.detect_drop', { session_id: sid, text }) .then(r => { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 00eeab2ec1..b25588885c 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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()) 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 ] diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 52193ff710..0a7ef55bad 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -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 { diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 6771a80d20..06bce66f8f 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -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 ` | Override the model for this run. | | `-t`, `--toolsets ` | Enable a comma-separated set of toolsets. | | `--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