{"repo":"NousResearch/hermes-agent","public_id":"845","display_label":"Guard print/isatty against closed stdout streams","summary":"These issues and PRs address crashes caused by closed or unavailable stdout handles raising ValueError or OSError, particularly in threaded, headless, or systemd contexts. Fixes target _SafeWriter, KawaiiSpinner.isatty(), and print() calls in CLI and conversation code paths.","subsystem":"agent","counts":{"members":14,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":5,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":955},"resolution":{"state":"partially_resolved","by":"pr-955,pr-3843"},"freshness":{"state":"stale","last_verified_at":"2026-03-30T01:21:28+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.12175824175824174},"fix_edges":[{"issue_number":811,"pr_number":955,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":845,"pr_number":963,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":845,"pr_number":1668,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":2128,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":2130,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":2137,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":2428,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":3534,"pr_number":3843,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":845,"title":"fix: guard print() calls in run_conversation() against OSError when stdout is unavailable (systemd/headless)","url":null,"state":"closed"},{"kind":"issue","number":2428,"title":"bug: _SafeWriter.isatty() raises ValueError on closed file (subagent ThreadPoolExecutor teardown)","url":null,"state":"closed"},{"kind":"issue","number":3106,"title":"bug(display): KawaiiSpinner isatty() crashes on closed stdout in gateway/threaded contexts","url":null,"state":"closed"},{"kind":"issue","number":3534,"title":"fix(io): _safe_print catches OSError but not ValueError — gateway threads crash on closed stdout","url":null,"state":"closed"},{"kind":"pr","number":858,"title":"fix: guard print() calls in run_conversation() against OSError","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#845","number":845}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":955,"title":"fix: _SafeWriter for broken stdout + doctor MiniMax + vision logging","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tools/vision_tools.py"]},{"kind":"pr","number":963,"title":"fix: guard all print() against OSError with _SafeWriter","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/test_run_agent.py"]},{"kind":"pr","number":1358,"title":"fix(core): wrap print calls with try-except OSError","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":1668,"title":"fix(core): guard print() calls in run_conversation() against OSError","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -856,6 +856,19 @@ def __init__(\n             else:\n                 print(f\"📊 Context limit: {self.context_compressor.context_length:,} tokens (auto-compression disabled)\")\n     \n+    @staticmethod\n+    def _safe_print(*args, **kwargs):\n+        \"\"\"Print that silently handles broken pipes / closed stdout.\n+\n+        In headless environments (systemd, Docker, nohup) stdout may become\n+        unavailable mid-session.  A raw ``print()`` raises ``OSError`` which\n+        can crash cron jobs and lose completed work.\n+        \"\"\"\n+        try:\n+            print(*args, **kwargs)\n+        except OSError:\n+            pass\n+\n     def _vprint(self, *args, force: bool = False, **kwargs):\n         \"\"\"Verbose print — suppressed when streaming TTS is active.\n ","@@ -864,7 +877,7 @@ def _vprint(self, *args, force: bool = False, **kwargs):\n         \"\"\"\n         if not force and self._has_stream_consumers():\n             return\n-        print(*args, **kwargs)\n+        self._safe_print(*args, **kwargs)\n \n     def _max_tokens_param(self, value: int) -> dict:\n         \"\"\"Return the correct max tokens kwarg for the current provider.","@@ -4752,7 +4765,7 @@ def run_conversation(\n         self._persist_user_message_idx = current_turn_user_idx\n         \n         if not self.quiet_mode:\n-            print(f\"💬 Starting conversation: '{user_message[:60]}{'...' if len(user_message) > 60 else ''}'\")\n+            self._safe_print(f\"💬 Starting conversation: '{user_message[:60]}{'...' if len(user_message) > 60 else ''}'\")\n         \n         # ── System prompt (cached per session for prefix caching) ──\n         # Built once on first call, reused for all subsequent calls.","@@ -4822,7 +4835,7 @@ def run_conversation(\n                     f\"{self.context_compressor.context_length:,}\",\n                 )\n                 if not self.quiet_mode:\n-                    print(\n+                    self._safe_print(\n                         f\"📦 Preflight compression: ~{_preflight_tokens:,} tokens \"\n                         f\">= {self.context_compressor.threshold_tokens:,} threshold\"\n                     )","@@ -4862,13 +4875,13 @@ def run_conversation(\n             if self._interrupt_requested:\n                 interrupted = True\n                 if not self.quiet_mode:\n-                    print(f\"\\n⚡ Breaking out of tool loop due to interrupt...\")\n+                    self._safe_print(f\"\\n⚡ Breaking out of tool loop due to interrupt...\")\n                 break\n             \n             api_call_count += 1\n             if not self.iteration_budget.consume():\n                 if not self.quiet_mode:\n-                    print(f\"\\n⚠️  Session iteration budget exhausted ({self.iteration_budget.max_total} total across agent + subagents)\")\n+                    self._safe_print(f\"\\n⚠️  Session iteration budget exhausted ({self.iteration_budget.max_total} total across agent + subagents)\")\n                 break\n \n             # Fire step_callback for gateway hooks (agent:step event)","@@ -5287,7 +5300,7 @@ def _stop_spinner():\n                         if self.context_compressor._context_probed:\n                             ctx = self.context_compressor.context_length\n                             save_context_length(self.model, self.base_url, ctx)\n-                            print(f\"{self.log_prefix}💾 Cached context length: {ctx:,} tokens for {self.model}\")\n+                            self._safe_print(f\"{self.log_prefix}💾 Cached context length: {ctx:,} tokens for {self.model}\")\n                             self.context_compressor._context_probed = False\n \n                         self.session_prompt_tokens += prompt_tokens","@@ -6129,12 +6142,15 @@ def _stop_spinner():\n                     messages.append(final_msg)\n                     \n                     if not self.quiet_mode:\n-                        print(f\"🎉 Conversation completed after {api_call_count} OpenAI-compatible API call(s)\")\n+                        self._safe_print(f\"🎉 Conversation completed after {api_call_count} OpenAI-compatible API call(s)\")\n                     break\n                 \n             except Exception as e:\n                 error_msg = f\"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}\"\n-                print(f\"❌ {error_msg}\")\n+                try:\n+                    print(f\"❌ {error_msg}\")\n+                except OSError:\n+                    logger.error(error_msg)\n                 \n                 if self.verbose_logging:\n                     logging.exception(\"Detailed error information:\")\n"]}]},{"kind":"pr","number":2430,"title":"fix: bug: _SafeWriter.isatty() raises ValueError on closed file (subag","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":2431,"title":"fix(io): catch ValueError in _SafeWriter for closed file handles","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2466","number":2466}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2466,"title":"fix: batch of 5 small contributor fixes — PortAudio, SafeWriter, IMAP, thread lock, prefill","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/email.py","hermes_state.py","run_agent.py","tools/voice_mode.py"],"fix_hunks":[{"file":"gateway/platforms/email.py","hunks":["@@ -230,7 +230,7 @@ async def connect(self) -> bool:\n             # Mark all existing messages as seen so we only process new ones\n             imap.select(\"INBOX\")\n             status, data = imap.uid(\"search\", None, \"ALL\")\n-            if status == \"OK\" and data[0]:\n+            if status == \"OK\" and data and data[0]:\n                 for uid in data[0].split():\n                     self._seen_uids.add(uid)\n             imap.logout()","@@ -295,7 +295,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:\n             imap.select(\"INBOX\")\n \n             status, data = imap.uid(\"search\", None, \"UNSEEN\")\n-            if status != \"OK\" or not data[0]:\n+            if status != \"OK\" or not data or not data[0]:\n                 imap.logout()\n                 return results\n "]},{"file":"hermes_state.py","hunks":["@@ -855,23 +855,25 @@ def search_sessions(\n \n     def session_count(self, source: str = None) -> int:\n         \"\"\"Count sessions, optionally filtered by source.\"\"\"\n-        if source:\n-            cursor = self._conn.execute(\n-                \"SELECT COUNT(*) FROM sessions WHERE source = ?\", (source,)\n-            )\n-        else:\n-            cursor = self._conn.execute(\"SELECT COUNT(*) FROM sessions\")\n-        return cursor.fetchone()[0]\n+        with self._lock:\n+            if source:\n+                cursor = self._conn.execute(\n+                    \"SELECT COUNT(*) FROM sessions WHERE source = ?\", (source,)\n+                )\n+            else:\n+                cursor = self._conn.execute(\"SELECT COUNT(*) FROM sessions\")\n+            return cursor.fetchone()[0]\n \n     def message_count(self, session_id: str = None) -> int:\n         \"\"\"Count messages, optionally for a specific session.\"\"\"\n-        if session_id:\n-            cursor = self._conn.execute(\n-                \"SELECT COUNT(*) FROM messages WHERE session_id = ?\", (session_id,)\n-            )\n-        else:\n-            cursor = self._conn.execute(\"SELECT COUNT(*) FROM messages\")\n-        return cursor.fetchone()[0]\n+        with self._lock:\n+            if session_id:\n+                cursor = self._conn.execute(\n+                    \"SELECT COUNT(*) FROM messages WHERE session_id = ?\", (session_id,)\n+                )\n+            else:\n+                cursor = self._conn.execute(\"SELECT COUNT(*) FROM messages\")\n+            return cursor.fetchone()[0]\n \n     # =========================================================================\n     # Export and cleanup"]},{"file":"run_agent.py","hunks":["@@ -108,7 +108,7 @@\n \n \n class _SafeWriter:\n-    \"\"\"Transparent stdio wrapper that catches OSError from broken pipes.\n+    \"\"\"Transparent stdio wrapper that catches OSError/ValueError from broken pipes.\n \n     When hermes-agent runs as a systemd service, Docker container, or headless\n     daemon, the stdout/stderr pipe can become unavailable (idle timeout, buffer","@@ -117,8 +117,13 @@ class _SafeWriter:\n     run_conversation() — especially via double-fault when an except handler\n     also tries to print.\n \n+    Additionally, when subagents run in ThreadPoolExecutor threads, the shared\n+    stdout handle can close between thread teardown and cleanup, raising\n+    ``ValueError: I/O operation on closed file`` instead of OSError.\n+\n     This wrapper delegates all writes to the underlying stream and silently\n-    catches OSError. It is transparent when the wrapped stream is healthy.\n+    catches both OSError and ValueError. It is transparent when the wrapped\n+    stream is healthy.\n     \"\"\"\n \n     __slots__ = (\"_inner\",)","@@ -129,13 +134,13 @@ def __init__(self, inner):\n     def write(self, data):\n         try:\n             return self._inner.write(data)\n-        except OSError:\n+        except (OSError, ValueError):\n             return len(data) if isinstance(data, str) else 0\n \n     def flush(self):\n         try:\n             self._inner.flush()\n-        except OSError:\n+        except (OSError, ValueError):\n             pass\n \n     def fileno(self):","@@ -144,7 +149,7 @@ def fileno(self):\n     def isatty(self):\n         try:\n             return self._inner.isatty()\n-        except OSError:\n+        except (OSError, ValueError):\n             return False\n \n     def __getattr__(self, name):","@@ -2438,7 +2443,18 @@ def _sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any\n                 \"Pre-call sanitizer: added %d stub tool result(s)\",\n                 len(missing_results),\n             )\n-\n+        # 3. Strip trailing empty assistant messages to prevent prefill rejection.\n+        # These can leak from Responses API reasoning-only turns (Codex/MiniMax)\n+        # where an empty assistant message is required by the Responses API but\n+        # must NOT be sent to Chat Completions or Anthropic Messages API providers.\n+        while (\n+            messages\n+            and messages[-1].get(\"role\") == \"assistant\"\n+            and not (messages[-1].get(\"content\") or \"\").strip()\n+            and not messages[-1].get(\"tool_calls\")\n+        ):\n+            logger.debug(\"Pre-call sanitizer: removed trailing empty assistant message\")\n+            messages = messages[:-1]\n         return messages\n \n     @staticmethod"]},{"file":"tools/voice_mode.py","hunks":["@@ -81,8 +81,15 @@ def detect_audio_environment() -> dict:\n                 warnings.append(\"No audio input/output devices detected\")\n         except Exception:\n             warnings.append(\"Audio subsystem error (PortAudio cannot query devices)\")\n-    except (ImportError, OSError):\n+    except ImportError:\n         warnings.append(\"Audio libraries not installed (pip install sounddevice numpy)\")\n+    except OSError:\n+        warnings.append(\n+            \"PortAudio system library not found -- install it first:\\n\"\n+            \"  Linux:  sudo apt-get install libportaudio2\\n\"\n+            \"  macOS:  brew install portaudio\\n\"\n+            \"Then retry /voice on.\"\n+        )\n \n     return {\n         \"available\": len(warnings) == 0,\n"]}]},{"kind":"pr","number":3760,"title":"CLI: handle closed stdout ValueError in safe print paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3843","number":3843}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":3843,"title":"fix(cli): handle closed stdout ValueError in safe print paths","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -1285,7 +1285,7 @@ def _safe_print(self, *args, **kwargs):\n         try:\n             fn = self._print_fn or print\n             fn(*args, **kwargs)\n-        except OSError:\n+        except (OSError, ValueError):\n             pass\n \n     def _vprint(self, *args, force: bool = False, **kwargs):","@@ -7909,7 +7909,7 @@ def _stop_spinner():\n                 error_msg = f\"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}\"\n                 try:\n                     print(f\"❌ {error_msg}\")\n-                except OSError:\n+                except (OSError, ValueError):\n                     logger.error(error_msg)\n                 \n                 if self.verbose_logging:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}