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:
Teknium
2026-08-28 04:59:06 -07:00
parent ad4adbbfeb
commit a5c7eed5f3
11 changed files with 347 additions and 19 deletions
+108 -5
View File
@@ -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
View File
@@ -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"
)
+1
View File
@@ -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())
+5 -2
View File
@@ -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)
}
+4
View File
@@ -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: {
+8 -2
View File
@@ -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 => {
+6 -2
View File
@@ -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
]
+25 -3
View File
@@ -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 {
+6 -4
View File
@@ -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