{"repo":"NousResearch/hermes-agent","public_id":"30623","display_label":"Oneshot mode (-z) silent failures and output bugs","summary":"Multiple reports and fixes around the oneshot CLI mode (-z) silently exiting with empty output, incorrect exit codes, and non-TTY/pipe environment failures. Work spans detection of empty responses, stdout routing, and fail-closed behavior.","subsystem":"hermes_cli","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35384,"merged_at":"2026-05-30T14:31:48+00:00"},"resolution":{"state":"resolved","by":"pr-35384"},"freshness":{"state":"verified","last_verified_at":"2026-05-30T14:31:48+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":30623,"pr_number":35384,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-30T14:31:48+00:00"},{"issue_number":35294,"pr_number":35384,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-30T14:31:48+00:00"}],"members":[{"kind":"issue","number":30623,"title":"[Bug]: `hermes -z` oneshot exits 0 silently before API call when stdout is non-TTY pipe (SSH/cron/subprocess)","url":null,"state":"closed"},{"kind":"pr","number":31917,"title":"fix(cli): dispatch oneshot before _prepare_agent_startup to prevent non-TTY crash (#30623)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30623","number":30623},{"kind":"pr","value":"#35384","number":35384},{"kind":"pr","value":"#1","number":1},{"kind":"pr","value":"#3","number":3}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":33818,"title":"fix(cli): run -z oneshot when stdout is non-TTY (SSH/cron/pipe)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35384","number":35384},{"kind":"pr","value":"#30623","number":30623}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":35384,"title":"fix(cli): oneshot (-z) fails closed on empty response or agent crash","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/oneshot.py","scripts/release.py","tests/hermes_cli/test_tui_resume_flow.py"],"fix_hunks":[{"file":"hermes_cli/oneshot.py","hunks":["@@ -174,28 +174,55 @@ def run_oneshot(\n     # Redirect stderr AND stdout to devnull for the entire call tree.\n     # We'll print the final response to the real stdout at the end.\n     real_stdout = sys.stdout\n+    real_stderr = sys.stderr\n     devnull = open(os.devnull, \"w\", encoding=\"utf-8\")\n \n+    response: Optional[str] = None\n+    failure: BaseException | None = None\n     try:\n         with redirect_stdout(devnull), redirect_stderr(devnull):\n-            response = _run_agent(\n-                prompt,\n-                model=model,\n-                provider=provider,\n-                toolsets=explicit_toolsets,\n-                use_config_toolsets=use_config_toolsets,\n-            )\n+            try:\n+                response = _run_agent(\n+                    prompt,\n+                    model=model,\n+                    provider=provider,\n+                    toolsets=explicit_toolsets,\n+                    use_config_toolsets=use_config_toolsets,\n+                )\n+            except BaseException as exc:  # noqa: BLE001\n+                # Capture anything that escapes the agent (including OSError\n+                # from prompt_toolkit/Vt100 when stdout is a non-TTY pipe,\n+                # KeyboardInterrupt, SystemExit, etc.) so we can surface it on\n+                # the real stderr instead of crashing past the redirect with a\n+                # traceback that the caller never sees. A silent exit in a\n+                # cron / SSH / subprocess context is the worst failure mode.\n+                # See #30623.\n+                failure = exc\n     finally:\n         try:\n             devnull.close()\n         except Exception:\n             pass\n \n-    if response:\n-        real_stdout.write(response)\n-        if not response.endswith(\"\\n\"):\n-            real_stdout.write(\"\\n\")\n-        real_stdout.flush()\n+    if failure is not None:\n+        # Re-raise control-flow exceptions so the parent handles them as usual\n+        # (Ctrl-C / explicit sys.exit() inside the agent).\n+        if isinstance(failure, (KeyboardInterrupt, SystemExit)):\n+            raise failure\n+        real_stderr.write(f\"hermes -z: agent failed: {failure}\\n\")\n+        real_stderr.flush()\n+        return 1\n+\n+    if not (response or \"\").strip():\n+        real_stderr.write(\"hermes -z: no final response was produced; treating the run as failed.\\n\")\n+        real_stderr.flush()\n+        return 1\n+\n+    assert response is not None  # narrowed by the empty-response guard above\n+    real_stdout.write(response)\n+    if not response.endswith(\"\\n\"):\n+        real_stdout.write(\"\\n\")\n+    real_stdout.flush()\n     return 0\n \n "]},{"file":"scripts/release.py","hunks":["@@ -656,6 +656,7 @@\n     \"[redacted:email]\": \"inchargeautomation-lab\",\n     \"[redacted:email]\": \"Bartok9\",\n     \"96944678+ymylive@users.noreply.github.com\": \"sweetcornna\",\n+    \"[redacted:email]\": \"briancl2\",\n     \"[redacted:email]\": \"CruxExperts\",\n     \"154585401+LeonSGP43@users.noreply.github.com\": \"LeonSGP43\",\n     \"12250313+Kailigithub@users.noreply.github.com\": \"Kailigithub\","]},{"file":"tests/hermes_cli/test_tui_resume_flow.py","hunks":["@@ -638,6 +638,60 @@ def test_oneshot_rejects_invalid_only_toolsets(monkeypatch, capsys):\n     assert \"did not contain any valid toolsets\" in err\n \n \n+def test_oneshot_fails_closed_on_empty_final_response(monkeypatch, capsys):\n+    _stub_plugin_discovery(monkeypatch)\n+    import hermes_cli.oneshot as oneshot_mod\n+\n+    monkeypatch.setattr(oneshot_mod, \"_run_agent\", lambda *_args, **_kwargs: \"\")\n+\n+    assert oneshot_mod.run_oneshot(\"hello\") == 1\n+    captured = capsys.readouterr()\n+    assert captured.out == \"\"\n+    assert \"no final response\" in captured.err\n+\n+\n+def test_oneshot_prints_nonempty_final_response(monkeypatch, capsys):\n+    _stub_plugin_discovery(monkeypatch)\n+    import hermes_cli.oneshot as oneshot_mod\n+\n+    monkeypatch.setattr(oneshot_mod, \"_run_agent\", lambda *_args, **_kwargs: \"done\")\n+\n+    assert oneshot_mod.run_oneshot(\"hello\") == 0\n+    captured = capsys.readouterr()\n+    assert captured.out == \"done\\n\"\n+    assert captured.err == \"\"\n+\n+\n+def test_oneshot_fails_closed_on_agent_exception(monkeypatch, capsys):\n+    _stub_plugin_discovery(monkeypatch)\n+    import hermes_cli.oneshot as oneshot_mod\n+\n+    def _boom(*_args, **_kwargs):\n+        raise OSError(\"not a TTY\")\n+\n+    monkeypatch.setattr(oneshot_mod, \"_run_agent\", _boom)\n+\n+    assert oneshot_mod.run_oneshot(\"hello\") == 1\n+    captured = capsys.readouterr()\n+    assert captured.out == \"\"\n+    assert \"agent failed\" in captured.err\n+    assert \"not a TTY\" in captured.err\n+\n+\n+def test_oneshot_reraises_keyboard_interrupt(monkeypatch):\n+    _stub_plugin_discovery(monkeypatch)\n+    import hermes_cli.oneshot as oneshot_mod\n+    import pytest as _pytest\n+\n+    def _interrupt(*_args, **_kwargs):\n+        raise KeyboardInterrupt\n+\n+    monkeypatch.setattr(oneshot_mod, \"_run_agent\", _interrupt)\n+\n+    with _pytest.raises(KeyboardInterrupt):\n+        oneshot_mod.run_oneshot(\"hello\")\n+\n+\n def test_oneshot_filters_invalid_toolsets_before_redirect(monkeypatch, capsys):\n     _stub_plugin_discovery(monkeypatch)\n     from hermes_cli.oneshot import _validate_explicit_toolsets\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}