From d41a214c1a8698ec34570b8e89fe3b881332f52f Mon Sep 17 00:00:00 2001 From: arceus77-7 <261276524+arceus77-7@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:45:29 -0400 Subject: [PATCH 1/8] feat(skills): add official optional 1password skill --- optional-skills/security/1password/SKILL.md | 130 ++++++++++++++++++ .../1password/references/cli-examples.md | 31 +++++ .../1password/references/get-started.md | 21 +++ 3 files changed, 182 insertions(+) create mode 100644 optional-skills/security/1password/SKILL.md create mode 100644 optional-skills/security/1password/references/cli-examples.md create mode 100644 optional-skills/security/1password/references/get-started.md diff --git a/optional-skills/security/1password/SKILL.md b/optional-skills/security/1password/SKILL.md new file mode 100644 index 000000000000..121e00f8485a --- /dev/null +++ b/optional-skills/security/1password/SKILL.md @@ -0,0 +1,130 @@ +--- +name: 1password +description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in, and reading/injecting secrets for commands. +version: 1.0.0 +author: Hermes Agent +license: MIT +metadata: + hermes: + tags: [security, secrets, 1password, op, cli] + category: security +--- + +# 1Password CLI + +Use this skill when the user wants secrets managed through 1Password instead of plaintext env vars or files. + +## Requirements + +- 1Password account +- 1Password desktop app installed and unlocked +- 1Password CLI (`op`) installed +- `tmux` available for stable authenticated sessions during Hermes terminal calls + +## When to Use + +- Install or configure 1Password CLI +- Sign in with `op signin` +- Read secret references like `op://Vault/Item/field` +- Inject secrets into config/templates using `op inject` +- Run commands with secret env vars via `op run` + +## Setup + +1. Install CLI: + +```bash +# macOS +brew install 1password-cli + +# Linux (official package/install docs) +# See references/get-started.md for distro-specific links. + +# Windows (winget) +winget install AgileBits.1Password.CLI +``` + +2. Verify: + +```bash +op --version +``` + +3. Enable app integration in 1Password desktop app: +- macOS: Settings -> Developer -> Integrate with 1Password CLI +- Linux/Windows: Settings -> Developer -> Integrate with 1Password CLI + +4. Ensure app is unlocked. + +## Hermes Execution Pattern (important) + +Hermes terminal commands are non-interactive by default and can lose auth context between calls. +For reliable `op` use, run sign-in and secret operations inside a dedicated tmux session. + +```bash +SOCKET_DIR="${TMPDIR:-/tmp}/hermes-tmux-sockets" +mkdir -p "$SOCKET_DIR" +SOCKET="$SOCKET_DIR/hermes-op.sock" +SESSION="op-auth-$(date +%Y%m%d-%H%M%S)" + +tmux -S "$SOCKET" new -d -s "$SESSION" -n shell + +# Sign in (approve in desktop app when prompted) +tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "eval \"\$(op signin --account my.1password.com)\"" Enter + +# Verify auth +tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op whoami" Enter + +# Example read +tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op read 'op://Private/Npmjs/one-time password?attribute=otp'" Enter + +# Capture output when needed +tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200 + +# Cleanup +tmux -S "$SOCKET" kill-session -t "$SESSION" +``` + +## Common Operations + +### Read a secret + +```bash +op read "op://app-prod/db/password" +``` + +### Get OTP + +```bash +op read "op://app-prod/npm/one-time password?attribute=otp" +``` + +### Inject into template + +```bash +echo "db_password: {{ op://app-prod/db/password }}" | op inject +``` + +### Run a command with secret env var + +```bash +export OPENAI_API_KEY="op://.../api key" +op run -- sh -c '[ -n "$OPENAI_API_KEY" ] && echo "OPENAI_API_KEY is set" || echo "OPENAI_API_KEY missing"' +``` + +## Guardrails + +- Never print raw secrets back to user unless they explicitly request the value. +- Prefer `op run` / `op inject` instead of writing secrets into files. +- If command fails with "account is not signed in", run `op signin` again in the same tmux session. +- If desktop app integration is unavailable (headless/CI), use service account token flow. + +## CI / Headless note + +For non-interactive use, authenticate with `OP_SERVICE_ACCOUNT_TOKEN` and avoid interactive `op signin`. + +## References + +- `references/get-started.md` +- `references/cli-examples.md` +- https://developer.1password.com/docs/cli/ diff --git a/optional-skills/security/1password/references/cli-examples.md b/optional-skills/security/1password/references/cli-examples.md new file mode 100644 index 000000000000..1e4b116b6030 --- /dev/null +++ b/optional-skills/security/1password/references/cli-examples.md @@ -0,0 +1,31 @@ +# op CLI examples + +## Sign-in and identity + +```bash +op signin +op signin --account my.1password.com +op whoami +op account list +``` + +## Read secrets + +```bash +op read "op://app-prod/db/password" +op read "op://app-prod/one-time password?attribute=otp" +``` + +## Inject secrets + +```bash +echo "api_key: {{ op://app-prod/openai/api key }}" | op inject +op inject -i config.tpl.yml -o config.yml +``` + +## Run command with secrets + +```bash +export DB_PASSWORD="op://app-prod/db/password" +op run -- sh -c '[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD is set"' +``` diff --git a/optional-skills/security/1password/references/get-started.md b/optional-skills/security/1password/references/get-started.md new file mode 100644 index 000000000000..5284d393943a --- /dev/null +++ b/optional-skills/security/1password/references/get-started.md @@ -0,0 +1,21 @@ +# 1Password CLI get-started (summary) + +Official docs: https://developer.1password.com/docs/cli/get-started/ + +## Core flow + +1. Install `op` CLI. +2. Enable desktop app integration in 1Password app. +3. Unlock app. +4. Run `op signin` and approve prompt. +5. Verify with `op whoami`. + +## Multiple accounts + +- Use `op signin --account ` +- Or set `OP_ACCOUNT` + +## Non-interactive / automation + +- Use service accounts and `OP_SERVICE_ACCOUNT_TOKEN` +- Prefer `op run` and `op inject` for runtime secret handling From 9a7ed81b4bb53ea2556d77b532d6f7e262bdd5c4 Mon Sep 17 00:00:00 2001 From: alireza78a Date: Fri, 13 Mar 2026 08:04:36 -0700 Subject: [PATCH 2/8] fix(cron): use atomic write in save_job_output to prevent data loss on crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit save_job_output() used bare open('w') which truncates the output file immediately. A crash or OOM kill between truncation and the completed write would silently wipe the job output. Write now goes to a temp file first, then os.replace() swaps it atomically — matching the existing save_jobs() pattern in the same file. Preserves _secure_file() permissions and uses safe cleanup on error. Cherry-picked from PR #874 by alireza78a, rebased onto current main with conflict resolution and fixes: - Kept _secure_dir/_secure_file security calls from PR #757 - Used except BaseException (not bare except) to match save_jobs pattern - Wrapped os.unlink in try/except OSError to avoid masking errors Co-authored-by: alireza78a --- cron/jobs.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cron/jobs.py b/cron/jobs.py index 6cbb168f0c59..186424c65877 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -431,8 +431,19 @@ def save_job_output(job_id: str, output: str): timestamp = _hermes_now().strftime("%Y-%m-%d_%H-%M-%S") output_file = job_output_dir / f"{timestamp}.md" - with open(output_file, 'w', encoding='utf-8') as f: - f.write(output) - _secure_file(output_file) + fd, tmp_path = tempfile.mkstemp(dir=str(job_output_dir), suffix='.tmp', prefix='.output_') + try: + with os.fdopen(fd, 'w', encoding='utf-8') as f: + f.write(output) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, output_file) + _secure_file(output_file) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise return output_file From 02a819b16e95f09cbd8200e22c60ffc083e217aa Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:07:12 -0700 Subject: [PATCH 3/8] feat(delegate): add observability metadata to subagent results (#1175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Home Assistant event filtering now closed by default Previously, when no watch_domains or watch_entities were configured, ALL state_changed events passed through to the agent, causing users to be flooded with notifications for every HA entity change. Now events are dropped by default unless the user explicitly configures: - watch_domains: list of domains to monitor (e.g. climate, light) - watch_entities: list of specific entity IDs to monitor - watch_all: true (new option — opt-in to receive all events) A warning is logged at connect time if no filters are configured, guiding users to set up their HA platform config. All 49 gateway HA tests + 52 HA tool tests pass. * docs: update Home Assistant integration documentation - homeassistant.md: Fix event filtering docs to reflect closed-by-default behavior. Add watch_all option. Replace Python dict config example with YAML. Fix defaults table (was incorrectly showing 'all'). Add required configuration warning admonition. - environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section. - messaging/index.md: Add Home Assistant to description, architecture diagram, platform toolsets table, and Next Steps links. * fix(terminal): strip provider env vars from background and PTY subprocesses Extends the env var blocklist from #1157 to also cover the two remaining leaky paths in process_registry.py: - spawn_local() PTY path (line 156) - spawn_local() background Popen path (line 197) Both were still using raw os.environ, leaking provider vars to background processes and interactive PTY sessions. Now uses the same dynamic _HERMES_PROVIDER_ENV_BLOCKLIST from local.py. Explicit env_vars passed to spawn_local() still override the blocklist, matching the existing behavior for callers that intentionally need these. Gap identified by PR #1004 (@PeterFile). * feat(delegate): add observability metadata to subagent results Enrich delegate_task results with metadata from the child AIAgent: - model: which model the child used - exit_reason: completed | interrupted | max_iterations - tokens.input / tokens.output: token counts - tool_trace: per-tool-call trace with byte sizes and ok/error status Tool trace uses tool_call_id matching to correctly pair parallel tool calls with their results, with a fallback for messages without IDs. Cherry-picked from PR #872 by @omerkaz, with fixes: - Fixed parallel tool call trace pairing (was always updating last entry) - Removed redundant 'iterations' field (identical to existing 'api_calls') - Added test for parallel tool call trace correctness Co-authored-by: omerkaz --------- Co-authored-by: omerkaz --- tests/tools/test_delegate.py | 163 +++++++++++++++++++++++++++++++++++ tools/delegate_tool.py | 58 +++++++++++++ 2 files changed, 221 insertions(+) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 113fe3dd75d6..680233b0f5d8 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -246,6 +246,169 @@ class TestDelegateTask(unittest.TestCase): self.assertEqual(kwargs["api_mode"], parent.api_mode) +class TestDelegateObservability(unittest.TestCase): + """Tests for enriched metadata returned by _run_single_child.""" + + def test_observability_fields_present(self): + """Completed child should return tool_trace, tokens, model, exit_reason.""" + parent = _make_mock_parent(depth=0) + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.model = "claude-sonnet-4-6" + mock_child.session_prompt_tokens = 5000 + mock_child.session_completion_tokens = 1200 + mock_child.run_conversation.return_value = { + "final_response": "done", + "completed": True, + "interrupted": False, + "api_calls": 3, + "messages": [ + {"role": "user", "content": "do something"}, + {"role": "assistant", "tool_calls": [ + {"id": "tc_1", "function": {"name": "web_search", "arguments": '{"query": "test"}'}} + ]}, + {"role": "tool", "tool_call_id": "tc_1", "content": '{"results": [1,2,3]}'}, + {"role": "assistant", "content": "done"}, + ], + } + MockAgent.return_value = mock_child + + result = json.loads(delegate_task(goal="Test observability", parent_agent=parent)) + entry = result["results"][0] + + # Core observability fields + self.assertEqual(entry["model"], "claude-sonnet-4-6") + self.assertEqual(entry["exit_reason"], "completed") + self.assertEqual(entry["tokens"]["input"], 5000) + self.assertEqual(entry["tokens"]["output"], 1200) + + # Tool trace + self.assertEqual(len(entry["tool_trace"]), 1) + self.assertEqual(entry["tool_trace"][0]["tool"], "web_search") + self.assertIn("args_bytes", entry["tool_trace"][0]) + self.assertIn("result_bytes", entry["tool_trace"][0]) + self.assertEqual(entry["tool_trace"][0]["status"], "ok") + + def test_tool_trace_detects_error(self): + """Tool results containing 'error' should be marked as error status.""" + parent = _make_mock_parent(depth=0) + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.model = "claude-sonnet-4-6" + mock_child.session_prompt_tokens = 0 + mock_child.session_completion_tokens = 0 + mock_child.run_conversation.return_value = { + "final_response": "failed", + "completed": True, + "interrupted": False, + "api_calls": 1, + "messages": [ + {"role": "assistant", "tool_calls": [ + {"id": "tc_1", "function": {"name": "terminal", "arguments": '{"cmd": "ls"}'}} + ]}, + {"role": "tool", "tool_call_id": "tc_1", "content": "Error: command not found"}, + ], + } + MockAgent.return_value = mock_child + + result = json.loads(delegate_task(goal="Test error trace", parent_agent=parent)) + trace = result["results"][0]["tool_trace"] + self.assertEqual(trace[0]["status"], "error") + + def test_parallel_tool_calls_paired_correctly(self): + """Parallel tool calls should each get their own result via tool_call_id matching.""" + parent = _make_mock_parent(depth=0) + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.model = "claude-sonnet-4-6" + mock_child.session_prompt_tokens = 3000 + mock_child.session_completion_tokens = 800 + mock_child.run_conversation.return_value = { + "final_response": "done", + "completed": True, + "interrupted": False, + "api_calls": 1, + "messages": [ + {"role": "assistant", "tool_calls": [ + {"id": "tc_a", "function": {"name": "web_search", "arguments": '{"q": "a"}'}}, + {"id": "tc_b", "function": {"name": "web_search", "arguments": '{"q": "b"}'}}, + {"id": "tc_c", "function": {"name": "terminal", "arguments": '{"cmd": "ls"}'}}, + ]}, + {"role": "tool", "tool_call_id": "tc_a", "content": '{"ok": true}'}, + {"role": "tool", "tool_call_id": "tc_b", "content": "Error: rate limited"}, + {"role": "tool", "tool_call_id": "tc_c", "content": "file1.txt\nfile2.txt"}, + {"role": "assistant", "content": "done"}, + ], + } + MockAgent.return_value = mock_child + + result = json.loads(delegate_task(goal="Test parallel", parent_agent=parent)) + trace = result["results"][0]["tool_trace"] + + # All three tool calls should have results + self.assertEqual(len(trace), 3) + + # First: web_search → ok + self.assertEqual(trace[0]["tool"], "web_search") + self.assertEqual(trace[0]["status"], "ok") + self.assertIn("result_bytes", trace[0]) + + # Second: web_search → error + self.assertEqual(trace[1]["tool"], "web_search") + self.assertEqual(trace[1]["status"], "error") + self.assertIn("result_bytes", trace[1]) + + # Third: terminal → ok + self.assertEqual(trace[2]["tool"], "terminal") + self.assertEqual(trace[2]["status"], "ok") + self.assertIn("result_bytes", trace[2]) + + def test_exit_reason_interrupted(self): + """Interrupted child should report exit_reason='interrupted'.""" + parent = _make_mock_parent(depth=0) + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.model = "claude-sonnet-4-6" + mock_child.session_prompt_tokens = 0 + mock_child.session_completion_tokens = 0 + mock_child.run_conversation.return_value = { + "final_response": "", + "completed": False, + "interrupted": True, + "api_calls": 2, + "messages": [], + } + MockAgent.return_value = mock_child + + result = json.loads(delegate_task(goal="Test interrupt", parent_agent=parent)) + self.assertEqual(result["results"][0]["exit_reason"], "interrupted") + + def test_exit_reason_max_iterations(self): + """Child that didn't complete and wasn't interrupted hit max_iterations.""" + parent = _make_mock_parent(depth=0) + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.model = "claude-sonnet-4-6" + mock_child.session_prompt_tokens = 0 + mock_child.session_completion_tokens = 0 + mock_child.run_conversation.return_value = { + "final_response": "", + "completed": False, + "interrupted": False, + "api_calls": 50, + "messages": [], + } + MockAgent.return_value = mock_child + + result = json.loads(delegate_task(goal="Test max iter", parent_agent=parent)) + self.assertEqual(result["results"][0]["exit_reason"], "max_iterations") + + class TestBlockedTools(unittest.TestCase): def test_blocked_tools_constant(self): for tool in ["delegate_task", "clarify", "memory", "send_message", "execute_code"]: diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 8ade49fe047e..76026be59d35 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -276,12 +276,70 @@ def _run_single_child( else: status = "failed" + # Build tool trace from conversation messages (already in memory). + # Uses tool_call_id to correctly pair parallel tool calls with results. + tool_trace: list[Dict[str, Any]] = [] + trace_by_id: Dict[str, Dict[str, Any]] = {} + messages = result.get("messages") or [] + if isinstance(messages, list): + for msg in messages: + if not isinstance(msg, dict): + continue + if msg.get("role") == "assistant": + for tc in (msg.get("tool_calls") or []): + fn = tc.get("function", {}) + entry_t = { + "tool": fn.get("name", "unknown"), + "args_bytes": len(fn.get("arguments", "")), + } + tool_trace.append(entry_t) + tc_id = tc.get("id") + if tc_id: + trace_by_id[tc_id] = entry_t + elif msg.get("role") == "tool": + content = msg.get("content", "") + is_error = bool( + content and "error" in content[:80].lower() + ) + result_meta = { + "result_bytes": len(content), + "status": "error" if is_error else "ok", + } + # Match by tool_call_id for parallel calls + tc_id = msg.get("tool_call_id") + target = trace_by_id.get(tc_id) if tc_id else None + if target is not None: + target.update(result_meta) + elif tool_trace: + # Fallback for messages without tool_call_id + tool_trace[-1].update(result_meta) + + # Determine exit reason + if interrupted: + exit_reason = "interrupted" + elif completed: + exit_reason = "completed" + else: + exit_reason = "max_iterations" + + # Extract token counts (safe for mock objects) + _input_tokens = getattr(child, "session_prompt_tokens", 0) + _output_tokens = getattr(child, "session_completion_tokens", 0) + _model = getattr(child, "model", None) + entry: Dict[str, Any] = { "task_index": task_index, "status": status, "summary": summary, "api_calls": api_calls, "duration_seconds": duration, + "model": _model if isinstance(_model, str) else None, + "exit_reason": exit_reason, + "tokens": { + "input": _input_tokens if isinstance(_input_tokens, (int, float)) else 0, + "output": _output_tokens if isinstance(_output_tokens, (int, float)) else 0, + }, + "tool_trace": tool_trace, } if status == "failed": entry["error"] = result.get("error", "Subagent did not produce a response.") From 9f676d1394baae28539bf745b849a398f43199d4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:39:21 -0700 Subject: [PATCH 4/8] feat(skills): add bundled opencode autonomous-agent skill Cherry-picked from PR #880 by @arceus77-7, rebased onto current main with corrections. Adds opencode skill under skills/autonomous-ai-agents/ with: - One-shot opencode run workflow - Interactive/background TUI session workflow - PR review workflow (including opencode pr command) - Parallel work patterns - TUI keybindings reference - Session/cost management - Smoke verification Tested with OpenCode v1.2.25. Fixed /exit bug (not a valid command), added missing flags (--file, --thinking, --variant), expanded docs. Co-authored-by: arceus77-7 <261276524+arceus77-7@users.noreply.github.com> --- skills/autonomous-ai-agents/opencode/SKILL.md | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 skills/autonomous-ai-agents/opencode/SKILL.md diff --git a/skills/autonomous-ai-agents/opencode/SKILL.md b/skills/autonomous-ai-agents/opencode/SKILL.md new file mode 100644 index 000000000000..37707dbced76 --- /dev/null +++ b/skills/autonomous-ai-agents/opencode/SKILL.md @@ -0,0 +1,218 @@ +--- +name: opencode +description: Delegate coding tasks to OpenCode CLI agent for feature implementation, refactoring, PR review, and long-running autonomous sessions. Requires the opencode CLI installed and authenticated. +version: 1.2.0 +author: Hermes Agent +license: MIT +metadata: + hermes: + tags: [Coding-Agent, OpenCode, Autonomous, Refactoring, Code-Review] + related_skills: [claude-code, codex, hermes-agent] +--- + +# OpenCode CLI + +Use [OpenCode](https://opencode.ai) as an autonomous coding worker orchestrated by Hermes terminal/process tools. OpenCode is a provider-agnostic, open-source AI coding agent with a TUI and CLI. + +## When to Use + +- User explicitly asks to use OpenCode +- You want an external coding agent to implement/refactor/review code +- You need long-running coding sessions with progress checks +- You want parallel task execution in isolated workdirs/worktrees + +## Prerequisites + +- OpenCode installed: `npm i -g opencode-ai@latest` or `brew install anomalyco/tap/opencode` +- Auth configured: `opencode auth login` or set provider env vars (OPENROUTER_API_KEY, etc.) +- Verify: `opencode auth list` should show at least one provider +- Git repository for code tasks (recommended) +- `pty=true` for interactive TUI sessions + +## Binary Resolution (Important) + +Shell environments may resolve different OpenCode binaries. If behavior differs between your terminal and Hermes, check: + +``` +terminal(command="which -a opencode") +terminal(command="opencode --version") +``` + +If needed, pin an explicit binary path: + +``` +terminal(command="$HOME/.opencode/bin/opencode run '...'", workdir="~/project", pty=true) +``` + +## One-Shot Tasks + +Use `opencode run` for bounded, non-interactive tasks: + +``` +terminal(command="opencode run 'Add retry logic to API calls and update tests'", workdir="~/project") +``` + +Attach context files with `-f`: + +``` +terminal(command="opencode run 'Review this config for security issues' -f config.yaml -f .env.example", workdir="~/project") +``` + +Show model thinking with `--thinking`: + +``` +terminal(command="opencode run 'Debug why tests fail in CI' --thinking", workdir="~/project") +``` + +Force a specific model: + +``` +terminal(command="opencode run 'Refactor auth module' --model openrouter/anthropic/claude-sonnet-4", workdir="~/project") +``` + +## Interactive Sessions (Background) + +For iterative work requiring multiple exchanges, start the TUI in background: + +``` +terminal(command="opencode", workdir="~/project", background=true, pty=true) +# Returns session_id + +# Send a prompt +process(action="submit", session_id="", data="Implement OAuth refresh flow and add tests") + +# Monitor progress +process(action="poll", session_id="") +process(action="log", session_id="") + +# Send follow-up input +process(action="submit", session_id="", data="Now add error handling for token expiry") + +# Exit cleanly — Ctrl+C +process(action="write", session_id="", data="\x03") +# Or just kill the process +process(action="kill", session_id="") +``` + +**Important:** Do NOT use `/exit` — it is not a valid OpenCode command and will open an agent selector dialog instead. Use Ctrl+C (`\x03`) or `process(action="kill")` to exit. + +### TUI Keybindings + +| Key | Action | +|-----|--------| +| `Enter` | Submit message (press twice if needed) | +| `Tab` | Switch between agents (build/plan) | +| `Ctrl+P` | Open command palette | +| `Ctrl+X L` | Switch session | +| `Ctrl+X M` | Switch model | +| `Ctrl+X N` | New session | +| `Ctrl+X E` | Open editor | +| `Ctrl+C` | Exit OpenCode | + +### Resuming Sessions + +After exiting, OpenCode prints a session ID. Resume with: + +``` +terminal(command="opencode -c", workdir="~/project", background=true, pty=true) # Continue last session +terminal(command="opencode -s ses_abc123", workdir="~/project", background=true, pty=true) # Specific session +``` + +## Common Flags + +| Flag | Use | +|------|-----| +| `run 'prompt'` | One-shot execution and exit | +| `--continue` / `-c` | Continue the last OpenCode session | +| `--session ` / `-s` | Continue a specific session | +| `--agent ` | Choose OpenCode agent (build or plan) | +| `--model provider/model` | Force specific model | +| `--format json` | Machine-readable output/events | +| `--file ` / `-f` | Attach file(s) to the message | +| `--thinking` | Show model thinking blocks | +| `--variant ` | Reasoning effort (high, max, minimal) | +| `--title ` | Name the session | +| `--attach ` | Connect to a running opencode server | + +## Procedure + +1. Verify tool readiness: + - `terminal(command="opencode --version")` + - `terminal(command="opencode auth list")` +2. For bounded tasks, use `opencode run '...'` (no pty needed). +3. For iterative tasks, start `opencode` with `background=true, pty=true`. +4. Monitor long tasks with `process(action="poll"|"log")`. +5. If OpenCode asks for input, respond via `process(action="submit", ...)`. +6. Exit with `process(action="write", data="\x03")` or `process(action="kill")`. +7. Summarize file changes, test results, and next steps back to user. + +## PR Review Workflow + +OpenCode has a built-in PR command: + +``` +terminal(command="opencode pr 42", workdir="~/project", pty=true) +``` + +Or review in a temporary clone for isolation: + +``` +terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && opencode run 'Review this PR vs main. Report bugs, security risks, test gaps, and style issues.' -f $(git diff origin/main --name-only | head -20 | tr '\n' ' ')", pty=true) +``` + +## Parallel Work Pattern + +Use separate workdirs/worktrees to avoid collisions: + +``` +terminal(command="opencode run 'Fix issue #101 and commit'", workdir="/tmp/issue-101", background=true, pty=true) +terminal(command="opencode run 'Add parser regression tests and commit'", workdir="/tmp/issue-102", background=true, pty=true) +process(action="list") +``` + +## Session & Cost Management + +List past sessions: + +``` +terminal(command="opencode session list") +``` + +Check token usage and costs: + +``` +terminal(command="opencode stats") +terminal(command="opencode stats --days 7 --models anthropic/claude-sonnet-4") +``` + +## Pitfalls + +- Interactive `opencode` (TUI) sessions require `pty=true`. The `opencode run` command does NOT need pty. +- `/exit` is NOT a valid command — it opens an agent selector. Use Ctrl+C to exit the TUI. +- PATH mismatch can select the wrong OpenCode binary/model config. +- If OpenCode appears stuck, inspect logs before killing: + - `process(action="log", session_id="")` +- Avoid sharing one working directory across parallel OpenCode sessions. +- Enter may need to be pressed twice to submit in the TUI (once to finalize text, once to send). + +## Verification + +Smoke test: + +``` +terminal(command="opencode run 'Respond with exactly: OPENCODE_SMOKE_OK'") +``` + +Success criteria: +- Output includes `OPENCODE_SMOKE_OK` +- Command exits without provider/model errors +- For code tasks: expected files changed and tests pass + +## Rules + +1. Prefer `opencode run` for one-shot automation — it's simpler and doesn't need pty. +2. Use interactive background mode only when iteration is needed. +3. Always scope OpenCode sessions to a single repo/workdir. +4. For long tasks, provide progress updates from `process` logs. +5. Report concrete outcomes (files changed, tests, remaining risks). +6. Exit interactive sessions with Ctrl+C or kill, never `/exit`. From 9667c71df8a44fcfcb9d9b01aa1a609203d675fb Mon Sep 17 00:00:00 2001 From: teknium1 Date: Fri, 13 Mar 2026 08:46:49 -0700 Subject: [PATCH 5/8] =?UTF-8?q?fix(skills):=20improve=201password=20skill?= =?UTF-8?q?=20=E2=80=94=20env=20var=20prompting,=20auth=20docs,=20broken?= =?UTF-8?q?=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #883 (arceus77-7): - Add setup.collect_secrets for OP_SERVICE_ACCOUNT_TOKEN so the skill prompts users to configure their token on first load - Fix broken code examples: garbled op run export line, truncated secret reference in cli-examples.md - Add Authentication Methods section documenting all 3 auth flows (service account, desktop app, connect server) with service account recommended for Hermes - Clarify tmux pattern is only needed for desktop app flow, not service account token flow - Credit original author (arceus77-7) in frontmatter - Add DESCRIPTION.md for security/ category Co-authored-by: arceus77-7 --- optional-skills/security/1password/SKILL.md | 54 +++++++++++++++---- .../1password/references/cli-examples.md | 2 +- optional-skills/security/DESCRIPTION.md | 3 ++ 3 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 optional-skills/security/DESCRIPTION.md diff --git a/optional-skills/security/1password/SKILL.md b/optional-skills/security/1password/SKILL.md index 121e00f8485a..b993c6cd7c10 100644 --- a/optional-skills/security/1password/SKILL.md +++ b/optional-skills/security/1password/SKILL.md @@ -2,12 +2,19 @@ name: 1password description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in, and reading/injecting secrets for commands. version: 1.0.0 -author: Hermes Agent +author: arceus77-7, enhanced by Hermes Agent license: MIT metadata: hermes: tags: [security, secrets, 1password, op, cli] category: security +setup: + help: "Create a service account at https://my.1password.com → Settings → Service Accounts" + collect_secrets: + - env_var: OP_SERVICE_ACCOUNT_TOKEN + prompt: "1Password Service Account Token" + provider_url: "https://developer.1password.com/docs/service-accounts/" + secret: true --- # 1Password CLI @@ -17,9 +24,9 @@ Use this skill when the user wants secrets managed through 1Password instead of ## Requirements - 1Password account -- 1Password desktop app installed and unlocked - 1Password CLI (`op`) installed -- `tmux` available for stable authenticated sessions during Hermes terminal calls +- One of: desktop app integration, service account token (`OP_SERVICE_ACCOUNT_TOKEN`), or Connect server +- `tmux` available for stable authenticated sessions during Hermes terminal calls (desktop app flow only) ## When to Use @@ -29,6 +36,31 @@ Use this skill when the user wants secrets managed through 1Password instead of - Inject secrets into config/templates using `op inject` - Run commands with secret env vars via `op run` +## Authentication Methods + +### Service Account (recommended for Hermes) + +Set `OP_SERVICE_ACCOUNT_TOKEN` in `~/.hermes/.env` (the skill will prompt for this on first load). +No desktop app needed. Supports `op read`, `op inject`, `op run`. + +```bash +export OP_SERVICE_ACCOUNT_TOKEN="your-token-here" +op whoami # verify — should show Type: SERVICE_ACCOUNT +``` + +### Desktop App Integration (interactive) + +1. Enable in 1Password desktop app: Settings → Developer → Integrate with 1Password CLI +2. Ensure app is unlocked +3. Run `op signin` and approve the biometric prompt + +### Connect Server (self-hosted) + +```bash +export OP_CONNECT_HOST="http://localhost:8080" +export OP_CONNECT_TOKEN="your-connect-token" +``` + ## Setup 1. Install CLI: @@ -50,16 +82,14 @@ winget install AgileBits.1Password.CLI op --version ``` -3. Enable app integration in 1Password desktop app: -- macOS: Settings -> Developer -> Integrate with 1Password CLI -- Linux/Windows: Settings -> Developer -> Integrate with 1Password CLI +3. Choose an auth method above and configure it. -4. Ensure app is unlocked. - -## Hermes Execution Pattern (important) +## Hermes Execution Pattern (desktop app flow) Hermes terminal commands are non-interactive by default and can lose auth context between calls. -For reliable `op` use, run sign-in and secret operations inside a dedicated tmux session. +For reliable `op` use with desktop app integration, run sign-in and secret operations inside a dedicated tmux session. + +Note: This is NOT needed when using `OP_SERVICE_ACCOUNT_TOKEN` — the token persists across terminal calls automatically. ```bash SOCKET_DIR="${TMPDIR:-/tmp}/hermes-tmux-sockets" @@ -108,7 +138,7 @@ echo "db_password: {{ op://app-prod/db/password }}" | op inject ### Run a command with secret env var ```bash -export OPENAI_API_KEY="op://.../api key" +export OPENAI_API_KEY="op://app-prod/openai/api key" op run -- sh -c '[ -n "$OPENAI_API_KEY" ] && echo "OPENAI_API_KEY is set" || echo "OPENAI_API_KEY missing"' ``` @@ -122,9 +152,11 @@ op run -- sh -c '[ -n "$OPENAI_API_KEY" ] && echo "OPENAI_API_KEY is set" || ech ## CI / Headless note For non-interactive use, authenticate with `OP_SERVICE_ACCOUNT_TOKEN` and avoid interactive `op signin`. +Service accounts require CLI v2.18.0+. ## References - `references/get-started.md` - `references/cli-examples.md` - https://developer.1password.com/docs/cli/ +- https://developer.1password.com/docs/service-accounts/ diff --git a/optional-skills/security/1password/references/cli-examples.md b/optional-skills/security/1password/references/cli-examples.md index 1e4b116b6030..4b2f5bd3ae05 100644 --- a/optional-skills/security/1password/references/cli-examples.md +++ b/optional-skills/security/1password/references/cli-examples.md @@ -13,7 +13,7 @@ op account list ```bash op read "op://app-prod/db/password" -op read "op://app-prod/one-time password?attribute=otp" +op read "op://app-prod/npm/one-time password?attribute=otp" ``` ## Inject secrets diff --git a/optional-skills/security/DESCRIPTION.md b/optional-skills/security/DESCRIPTION.md new file mode 100644 index 000000000000..7087fb30bd94 --- /dev/null +++ b/optional-skills/security/DESCRIPTION.md @@ -0,0 +1,3 @@ +# Security + +Skills for secrets management, credential handling, and security tooling integrations. From d425901bae8c44d6eb8a1780e12e8e87c47a14c0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:51:45 -0700 Subject: [PATCH 6/8] fix: report cronjob tool as available in hermes doctor Set HERMES_INTERACTIVE=1 via setdefault in run_doctor() so CLI-gated tool checks (like cronjob) see the same context as the interactive CLI. Cherry-picked from PR #895 by @stablegenius49. Fixes #878 Co-authored-by: stablegenius49 --- hermes_cli/doctor.py | 4 +++ tests/hermes_cli/test_doctor.py | 44 ++++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 88c767c741f0..9947b67fafca 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -97,6 +97,10 @@ def check_info(text: str): def run_doctor(args): """Run diagnostic checks.""" should_fix = getattr(args, 'fix', False) + + # Doctor runs from the interactive CLI, so CLI-gated tool availability + # checks (like cronjob management) should see the same context as `hermes`. + os.environ.setdefault("HERMES_INTERACTIVE", "1") issues = [] manual_issues = [] # issues that can't be auto-fixed diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 5c038e3f580b..f30cf87d24f7 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -1,14 +1,21 @@ -"""Tests for hermes doctor helpers.""" +"""Tests for hermes_cli.doctor.""" +import os +import sys +import types +from argparse import Namespace from types import SimpleNamespace +import pytest + import hermes_cli.doctor as doctor +from hermes_cli import doctor as doctor_mod from hermes_cli.doctor import _has_provider_env_config class TestProviderEnvDetection: def test_detects_openai_api_key(self): - content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=sk-test-key\n" + content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***" assert _has_provider_env_config(content) def test_detects_custom_endpoint_without_openrouter_key(self): @@ -47,7 +54,7 @@ class TestDoctorToolAvailabilityOverrides: class TestHonchoDoctorConfigDetection: def test_reports_configured_when_enabled_with_api_key(self, monkeypatch): - fake_config = SimpleNamespace(enabled=True, api_key="honcho-test-key") + fake_config = SimpleNamespace(enabled=True, api_key="***") monkeypatch.setattr( "honcho_integration.client.HonchoClientConfig.from_global_config", @@ -57,7 +64,7 @@ class TestHonchoDoctorConfigDetection: assert doctor._honcho_is_configured_for_doctor() def test_reports_not_configured_without_api_key(self, monkeypatch): - fake_config = SimpleNamespace(enabled=True, api_key=None) + fake_config = SimpleNamespace(enabled=True, api_key="") monkeypatch.setattr( "honcho_integration.client.HonchoClientConfig.from_global_config", @@ -65,3 +72,32 @@ class TestHonchoDoctorConfigDetection: ) assert not doctor._honcho_is_configured_for_doctor() + + +def test_run_doctor_sets_interactive_env_for_tool_checks(monkeypatch, tmp_path): + """Doctor should present CLI-gated tools as available in CLI context.""" + project_root = tmp_path / "project" + hermes_home = tmp_path / ".hermes" + project_root.mkdir() + hermes_home.mkdir() + + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project_root) + monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + + seen = {} + + def fake_check_tool_availability(*args, **kwargs): + seen["interactive"] = os.getenv("HERMES_INTERACTIVE") + raise SystemExit(0) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=fake_check_tool_availability, + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + with pytest.raises(SystemExit): + doctor_mod.run_doctor(Namespace(fix=False)) + + assert seen["interactive"] == "1" From b8b45bfb77503c89481407f794c5e2940429b9dd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:52:54 -0700 Subject: [PATCH 7/8] feat(discord): add /thread command, auto_thread config, and media metadata fix (#1178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /thread slash command that creates a Discord thread and starts a new Hermes session in it. The starter message (if provided) becomes the first user input in the new session. - Add discord.auto_thread config option (DISCORD_AUTO_THREAD env var): when enabled, every message in a text channel automatically creates a thread, allowing parallel isolated sessions. - Fix Discord media method signatures to accept metadata kwarg (send_voice, send_image_file, send_image) — prevents TypeError when the base adapter passes platform metadata. - Fix test mock isolation: add app_commands and ForumChannel to discord mocks so tests pass in full-suite runs. Based on PRs #866 and #1109 by insecurejezza, modified per review: removed /channel command (unsafe), added auto_thread feature, made /thread dispatch new sessions. Co-authored-by: insecurejezza --- gateway/config.py | 2 + gateway/platforms/discord.py | 222 +++++++++- tests/gateway/test_discord_free_response.py | 3 + tests/gateway/test_discord_media_metadata.py | 9 + tests/gateway/test_discord_slash_commands.py | 434 +++++++++++++++++++ 5 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 tests/gateway/test_discord_media_metadata.py create mode 100644 tests/gateway/test_discord_slash_commands.py diff --git a/gateway/config.py b/gateway/config.py index 5d3dfa9f59f9..d325abcde85d 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -304,6 +304,8 @@ def load_gateway_config() -> GatewayConfig: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) + if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"): + os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() except Exception: pass diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index c7ae2ada5dbe..257756addfb1 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -14,6 +14,8 @@ from typing import Dict, List, Optional, Any logger = logging.getLogger(__name__) +VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080} + try: import discord from discord import Message as DiscordMessage, Intents @@ -251,6 +253,7 @@ class DiscordAdapter(BasePlatformAdapter): audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send audio as a Discord file attachment.""" if not self._client: @@ -289,6 +292,7 @@ class DiscordAdapter(BasePlatformAdapter): image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a local image file natively as a Discord file attachment.""" if not self._client: @@ -326,6 +330,7 @@ class DiscordAdapter(BasePlatformAdapter): image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an image natively as a Discord file attachment.""" if not self._client: @@ -711,6 +716,21 @@ class DiscordAdapter(BasePlatformAdapter): except Exception as e: logger.debug("Discord followup failed: %s", e) + @tree.command(name="thread", description="Create a new thread and start a Hermes session in it") + @discord.app_commands.describe( + name="Thread name", + message="Optional first message to send to Hermes in the thread", + auto_archive_duration="Auto-archive in minutes (60, 1440, 4320, 10080)", + ) + async def slash_thread( + interaction: discord.Interaction, + name: str, + message: str = "", + auto_archive_duration: int = 1440, + ): + await interaction.response.defer(ephemeral=True) + await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration) + def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent: """Build a MessageEvent from a Discord slash command interaction.""" is_dm = isinstance(interaction.channel, discord.DMChannel) @@ -741,6 +761,188 @@ class DiscordAdapter(BasePlatformAdapter): raw_message=interaction, ) + # ------------------------------------------------------------------ + # Thread creation helpers + # ------------------------------------------------------------------ + + async def _handle_thread_create_slash( + self, + interaction: discord.Interaction, + name: str, + message: str = "", + auto_archive_duration: int = 1440, + ) -> None: + """Create a Discord thread from a slash command and start a session in it.""" + result = await self._create_thread( + interaction, + name=name, + message=message, + auto_archive_duration=auto_archive_duration, + ) + + if not result.get("success"): + error = result.get("error", "unknown error") + await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True) + return + + thread_id = result.get("thread_id") + thread_name = result.get("thread_name") or name + + # Tell the user where the thread is + link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**" + await interaction.followup.send(f"Created thread {link}", ephemeral=True) + + # If a message was provided, kick off a new Hermes session in the thread + starter = (message or "").strip() + if starter and thread_id: + await self._dispatch_thread_session(interaction, thread_id, thread_name, starter) + + async def _dispatch_thread_session( + self, + interaction: discord.Interaction, + thread_id: str, + thread_name: str, + text: str, + ) -> None: + """Build a MessageEvent pointing at a thread and send it through handle_message.""" + guild_name = "" + if hasattr(interaction, "guild") and interaction.guild: + guild_name = interaction.guild.name + + chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name + + source = self.build_source( + chat_id=thread_id, + chat_name=chat_name, + chat_type="thread", + user_id=str(interaction.user.id), + user_name=interaction.user.display_name, + thread_id=thread_id, + ) + + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + raw_message=interaction, + ) + await self.handle_message(event) + + def _thread_parent_channel(self, channel: Any) -> Any: + """Return the parent text channel when invoked from a thread.""" + return getattr(channel, "parent", None) or channel + + async def _resolve_interaction_channel(self, interaction: discord.Interaction) -> Optional[Any]: + """Return the interaction channel, fetching it if the payload is partial.""" + channel = getattr(interaction, "channel", None) + if channel is not None: + return channel + if not self._client: + return None + channel_id = getattr(interaction, "channel_id", None) + if channel_id is None: + return None + channel = self._client.get_channel(int(channel_id)) + if channel is not None: + return channel + try: + return await self._client.fetch_channel(int(channel_id)) + except Exception: + return None + + async def _create_thread( + self, + interaction: discord.Interaction, + *, + name: str, + message: str = "", + auto_archive_duration: int = 1440, + ) -> Dict[str, Any]: + """Create a thread in the current Discord channel. + + Tries ``parent_channel.create_thread()`` first. If Discord rejects + that (e.g. permission issues), falls back to sending a seed message + and creating the thread from it. + """ + name = (name or "").strip() + if not name: + return {"error": "Thread name is required."} + + if auto_archive_duration not in VALID_THREAD_AUTO_ARCHIVE_MINUTES: + allowed = ", ".join(str(v) for v in sorted(VALID_THREAD_AUTO_ARCHIVE_MINUTES)) + return {"error": f"auto_archive_duration must be one of: {allowed}."} + + channel = await self._resolve_interaction_channel(interaction) + if channel is None: + return {"error": "Could not resolve the current Discord channel."} + if isinstance(channel, discord.DMChannel): + return {"error": "Discord threads can only be created inside server text channels, not DMs."} + + parent_channel = self._thread_parent_channel(channel) + if parent_channel is None: + return {"error": "Could not determine a parent text channel for the new thread."} + + display_name = getattr(getattr(interaction, "user", None), "display_name", None) or "unknown user" + reason = f"Requested by {display_name} via /thread" + starter_message = (message or "").strip() + + try: + thread = await parent_channel.create_thread( + name=name, + auto_archive_duration=auto_archive_duration, + reason=reason, + ) + if starter_message: + await thread.send(starter_message) + return { + "success": True, + "thread_id": str(thread.id), + "thread_name": getattr(thread, "name", None) or name, + } + except Exception as direct_error: + try: + seed_content = starter_message or f"\U0001f9f5 Thread created by Hermes: **{name}**" + seed_msg = await parent_channel.send(seed_content) + thread = await seed_msg.create_thread( + name=name, + auto_archive_duration=auto_archive_duration, + reason=reason, + ) + return { + "success": True, + "thread_id": str(thread.id), + "thread_name": getattr(thread, "name", None) or name, + } + except Exception as fallback_error: + return { + "error": ( + "Discord rejected direct thread creation and the fallback also failed. " + f"Direct error: {direct_error}. Fallback error: {fallback_error}" + ) + } + + # ------------------------------------------------------------------ + # Auto-thread helpers + # ------------------------------------------------------------------ + + async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: + """Create a thread from a user message for auto-threading. + + Returns the created thread object, or ``None`` on failure. + """ + # Build a short thread name from the message + content = (message.content or "").strip() + thread_name = content[:80] if content else "Hermes" + if len(content) > 80: + thread_name = thread_name[:77] + "..." + + try: + thread = await message.create_thread(name=thread_name, auto_archive_duration=1440) + return thread + except Exception as e: + logger.warning("[%s] Auto-thread creation failed: %s", self.name, e) + return None + async def send_exec_approval( self, chat_id: str, command: str, approval_id: str ) -> SendResult: @@ -852,6 +1054,19 @@ class DiscordAdapter(BasePlatformAdapter): message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip() message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip() + # Auto-thread: when enabled, automatically create a thread for every + # new message in a text channel so each conversation is isolated. + # Messages already inside threads or DMs are unaffected. + auto_threaded_channel = None + if not is_thread and not isinstance(message.channel, discord.DMChannel): + auto_thread = os.getenv("DISCORD_AUTO_THREAD", "").lower() in ("true", "1", "yes") + if auto_thread: + thread = await self._auto_create_thread(message) + if thread: + is_thread = True + thread_id = str(thread.id) + auto_threaded_channel = thread + # Determine message type msg_type = MessageType.TEXT if message.content.startswith("/"): @@ -870,13 +1085,16 @@ class DiscordAdapter(BasePlatformAdapter): msg_type = MessageType.DOCUMENT break + # When auto-threading kicked in, route responses to the new thread + effective_channel = auto_threaded_channel or message.channel + # Determine chat type if isinstance(message.channel, discord.DMChannel): chat_type = "dm" chat_name = message.author.name elif is_thread: chat_type = "thread" - chat_name = self._format_thread_chat_name(message.channel) + chat_name = self._format_thread_chat_name(effective_channel) else: chat_type = "group" chat_name = getattr(message.channel, "name", str(message.channel.id)) @@ -888,7 +1106,7 @@ class DiscordAdapter(BasePlatformAdapter): # Build source source = self.build_source( - chat_id=str(message.channel.id), + chat_id=str(effective_channel.id), chat_name=chat_name, chat_type=chat_type, user_id=str(message.author.id), diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index fd9eacab253b..ff15326dbbc1 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -27,6 +27,9 @@ def _ensure_discord_mock(): discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) discord_mod.Interaction = object discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + ) ext_mod = MagicMock() commands_mod = MagicMock() diff --git a/tests/gateway/test_discord_media_metadata.py b/tests/gateway/test_discord_media_metadata.py new file mode 100644 index 000000000000..a98ac4fc043e --- /dev/null +++ b/tests/gateway/test_discord_media_metadata.py @@ -0,0 +1,9 @@ +import inspect + +from gateway.platforms.discord import DiscordAdapter + + +def test_discord_media_methods_accept_metadata_kwarg(): + for method_name in ("send_voice", "send_image_file", "send_image"): + signature = inspect.signature(getattr(DiscordAdapter, method_name)) + assert "metadata" in signature.parameters, method_name diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py new file mode 100644 index 000000000000..0b8cb04c7574 --- /dev/null +++ b/tests/gateway/test_discord_slash_commands.py @@ -0,0 +1,434 @@ +"""Tests for native Discord slash command fast-paths (thread creation & auto-thread).""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch +import sys + +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.Interaction = object + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + ) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + +from gateway.platforms.discord import DiscordAdapter # noqa: E402 + + +class FakeTree: + def __init__(self): + self.commands = {} + + def command(self, *, name, description): + def decorator(fn): + self.commands[name] = fn + return fn + + return decorator + + +@pytest.fixture +def adapter(): + config = PlatformConfig(enabled=True, token="***") + adapter = DiscordAdapter(config) + adapter._client = SimpleNamespace( + tree=FakeTree(), + get_channel=lambda _id: None, + fetch_channel=AsyncMock(), + user=SimpleNamespace(id=99999, name="HermesBot"), + ) + return adapter + + +# ------------------------------------------------------------------ +# /thread slash command registration +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_registers_native_thread_slash_command(adapter): + adapter._handle_thread_create_slash = AsyncMock() + adapter._register_slash_commands() + + command = adapter._client.tree.commands["thread"] + interaction = SimpleNamespace( + response=SimpleNamespace(defer=AsyncMock()), + ) + + await command(interaction, name="Planning", message="", auto_archive_duration=1440) + + interaction.response.defer.assert_awaited_once_with(ephemeral=True) + adapter._handle_thread_create_slash.assert_awaited_once_with(interaction, "Planning", "", 1440) + + +# ------------------------------------------------------------------ +# _handle_thread_create_slash — success, session dispatch, failure +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_handle_thread_create_slash_reports_success(adapter): + created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock()) + parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread), send=AsyncMock()) + interaction_channel = SimpleNamespace(parent=parent_channel) + interaction = SimpleNamespace( + channel=interaction_channel, + channel_id=123, + user=SimpleNamespace(display_name="Jezza", id=42), + guild=SimpleNamespace(name="TestGuild"), + followup=SimpleNamespace(send=AsyncMock()), + ) + + await adapter._handle_thread_create_slash(interaction, "Planning", "Kickoff", 1440) + + parent_channel.create_thread.assert_awaited_once_with( + name="Planning", + auto_archive_duration=1440, + reason="Requested by Jezza via /thread", + ) + created_thread.send.assert_awaited_once_with("Kickoff") + # Thread link shown to user + interaction.followup.send.assert_awaited() + args, kwargs = interaction.followup.send.await_args + assert "<#555>" in args[0] + assert kwargs["ephemeral"] is True + + +@pytest.mark.asyncio +async def test_handle_thread_create_slash_dispatches_session_when_message_provided(adapter): + """When a message is given, _dispatch_thread_session should be called.""" + created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock()) + parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread)) + interaction = SimpleNamespace( + channel=SimpleNamespace(parent=parent_channel), + channel_id=123, + user=SimpleNamespace(display_name="Jezza", id=42), + guild=SimpleNamespace(name="TestGuild"), + followup=SimpleNamespace(send=AsyncMock()), + ) + + adapter._dispatch_thread_session = AsyncMock() + + await adapter._handle_thread_create_slash(interaction, "Planning", "Hello Hermes", 1440) + + adapter._dispatch_thread_session.assert_awaited_once_with( + interaction, "555", "Planning", "Hello Hermes", + ) + + +@pytest.mark.asyncio +async def test_handle_thread_create_slash_no_dispatch_without_message(adapter): + """Without a message, no session dispatch should occur.""" + created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock()) + parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread)) + interaction = SimpleNamespace( + channel=SimpleNamespace(parent=parent_channel), + channel_id=123, + user=SimpleNamespace(display_name="Jezza", id=42), + guild=SimpleNamespace(name="TestGuild"), + followup=SimpleNamespace(send=AsyncMock()), + ) + + adapter._dispatch_thread_session = AsyncMock() + + await adapter._handle_thread_create_slash(interaction, "Planning", "", 1440) + + adapter._dispatch_thread_session.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_handle_thread_create_slash_falls_back_to_seed_message(adapter): + created_thread = SimpleNamespace(id=555, name="Planning") + seed_message = SimpleNamespace(id=777, create_thread=AsyncMock(return_value=created_thread)) + channel = SimpleNamespace( + create_thread=AsyncMock(side_effect=RuntimeError("direct failed")), + send=AsyncMock(return_value=seed_message), + ) + interaction = SimpleNamespace( + channel=channel, + channel_id=123, + user=SimpleNamespace(display_name="Jezza", id=42), + guild=SimpleNamespace(name="TestGuild"), + followup=SimpleNamespace(send=AsyncMock()), + ) + + await adapter._handle_thread_create_slash(interaction, "Planning", "Kickoff", 1440) + + channel.send.assert_awaited_once_with("Kickoff") + seed_message.create_thread.assert_awaited_once_with( + name="Planning", + auto_archive_duration=1440, + reason="Requested by Jezza via /thread", + ) + interaction.followup.send.assert_awaited() + + +@pytest.mark.asyncio +async def test_handle_thread_create_slash_reports_failure(adapter): + channel = SimpleNamespace( + create_thread=AsyncMock(side_effect=RuntimeError("direct failed")), + send=AsyncMock(side_effect=RuntimeError("nope")), + ) + interaction = SimpleNamespace( + channel=channel, + channel_id=123, + user=SimpleNamespace(display_name="Jezza", id=42), + followup=SimpleNamespace(send=AsyncMock()), + ) + + await adapter._handle_thread_create_slash(interaction, "Planning", "", 1440) + + interaction.followup.send.assert_awaited_once() + args, kwargs = interaction.followup.send.await_args + assert "Failed to create thread:" in args[0] + assert "nope" in args[0] + assert kwargs["ephemeral"] is True + + +# ------------------------------------------------------------------ +# _dispatch_thread_session — builds correct event and routes it +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_dispatch_thread_session_builds_thread_event(adapter): + """Dispatched event should have chat_type=thread and chat_id=thread_id.""" + interaction = SimpleNamespace( + user=SimpleNamespace(display_name="Jezza", id=42), + guild=SimpleNamespace(name="TestGuild"), + ) + + captured_events = [] + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + await adapter._dispatch_thread_session(interaction, "555", "Planning", "Hello!") + + assert len(captured_events) == 1 + event = captured_events[0] + assert event.text == "Hello!" + assert event.source.chat_id == "555" + assert event.source.chat_type == "thread" + assert event.source.thread_id == "555" + assert "TestGuild" in event.source.chat_name + + +# ------------------------------------------------------------------ +# Auto-thread: _auto_create_thread +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_auto_create_thread_uses_message_content_as_name(adapter): + thread = SimpleNamespace(id=999, name="Hello world") + message = SimpleNamespace( + content="Hello world, how are you?", + create_thread=AsyncMock(return_value=thread), + ) + + result = await adapter._auto_create_thread(message) + + assert result is thread + message.create_thread.assert_awaited_once() + call_kwargs = message.create_thread.await_args[1] + assert call_kwargs["name"] == "Hello world, how are you?" + assert call_kwargs["auto_archive_duration"] == 1440 + + +@pytest.mark.asyncio +async def test_auto_create_thread_truncates_long_names(adapter): + long_text = "a" * 200 + thread = SimpleNamespace(id=999, name="truncated") + message = SimpleNamespace( + content=long_text, + create_thread=AsyncMock(return_value=thread), + ) + + result = await adapter._auto_create_thread(message) + + assert result is thread + call_kwargs = message.create_thread.await_args[1] + assert len(call_kwargs["name"]) <= 80 + assert call_kwargs["name"].endswith("...") + + +@pytest.mark.asyncio +async def test_auto_create_thread_returns_none_on_failure(adapter): + message = SimpleNamespace( + content="Hello", + create_thread=AsyncMock(side_effect=RuntimeError("no perms")), + ) + + result = await adapter._auto_create_thread(message) + assert result is None + + +# ------------------------------------------------------------------ +# Auto-thread integration in _handle_message +# ------------------------------------------------------------------ + + +import discord as _discord_mod # noqa: E402 — mock or real, used below + + +class _FakeTextChannel: + """A channel that is NOT a discord.Thread or discord.DMChannel.""" + + def __init__(self, channel_id=100, name="general", guild_name="TestGuild"): + self.id = channel_id + self.name = name + self.guild = SimpleNamespace(name=guild_name, id=1) + self.topic = None + + +class _FakeThreadChannel(_discord_mod.Thread): + """isinstance(ch, discord.Thread) → True.""" + + def __init__(self, channel_id=200, name="existing-thread", guild_name="TestGuild", parent_id=100): + # Don't call super().__init__ — mock Thread is just an empty type + self.id = channel_id + self.name = name + self.guild = SimpleNamespace(name=guild_name, id=1) + self.topic = None + self.parent = SimpleNamespace(id=parent_id, name="general", guild=SimpleNamespace(name=guild_name, id=1)) + + +def _fake_message(channel, *, content="Hello", author_id=42, display_name="Jezza"): + return SimpleNamespace( + author=SimpleNamespace(id=author_id, display_name=display_name, bot=False), + content=content, + channel=channel, + attachments=[], + mentions=[], + reference=None, + created_at=None, + id=12345, + ) + + +@pytest.mark.asyncio +async def test_auto_thread_creates_thread_and_redirects(adapter, monkeypatch): + """When DISCORD_AUTO_THREAD=true, a new thread is created and the event routes there.""" + monkeypatch.setenv("DISCORD_AUTO_THREAD", "true") + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + + thread = SimpleNamespace(id=999, name="Hello") + adapter._auto_create_thread = AsyncMock(return_value=thread) + + captured_events = [] + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + msg = _fake_message(_FakeTextChannel(), content="Hello world") + + await adapter._handle_message(msg) + + adapter._auto_create_thread.assert_awaited_once_with(msg) + assert len(captured_events) == 1 + event = captured_events[0] + assert event.source.chat_id == "999" # redirected to thread + assert event.source.chat_type == "thread" + assert event.source.thread_id == "999" + + +@pytest.mark.asyncio +async def test_auto_thread_disabled_by_default(adapter, monkeypatch): + """Without DISCORD_AUTO_THREAD, messages stay in the channel.""" + monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + + adapter._auto_create_thread = AsyncMock() + + captured_events = [] + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + msg = _fake_message(_FakeTextChannel()) + + await adapter._handle_message(msg) + + adapter._auto_create_thread.assert_not_awaited() + assert len(captured_events) == 1 + assert captured_events[0].source.chat_id == "100" # stays in channel + + +@pytest.mark.asyncio +async def test_auto_thread_skips_threads_and_dms(adapter, monkeypatch): + """Auto-thread should not create threads inside existing threads.""" + monkeypatch.setenv("DISCORD_AUTO_THREAD", "true") + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + + adapter._auto_create_thread = AsyncMock() + + captured_events = [] + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + msg = _fake_message(_FakeThreadChannel()) + + await adapter._handle_message(msg) + + adapter._auto_create_thread.assert_not_awaited() # should NOT auto-thread + + +# ------------------------------------------------------------------ +# Config bridge +# ------------------------------------------------------------------ + + +def test_discord_auto_thread_config_bridge(monkeypatch, tmp_path): + """discord.auto_thread in config.yaml should be bridged to DISCORD_AUTO_THREAD env var.""" + import yaml + from pathlib import Path + + # Write a config.yaml the loader will find + hermes_dir = tmp_path / ".hermes" + hermes_dir.mkdir() + config_path = hermes_dir / "config.yaml" + config_path.write_text(yaml.dump({ + "discord": {"auto_thread": True}, + })) + + monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + from gateway.config import load_gateway_config + load_gateway_config() + + import os + assert os.getenv("DISCORD_AUTO_THREAD") == "true" From 938edc6466ab889c1e2bab671043971486845780 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Fri, 13 Mar 2026 08:56:06 -0700 Subject: [PATCH 8/8] fix(skills): use generic example in 1password op run snippet Replace OPENAI_API_KEY with DB_PASSWORD to avoid implying the skill is OpenAI-related. --- optional-skills/security/1password/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/optional-skills/security/1password/SKILL.md b/optional-skills/security/1password/SKILL.md index b993c6cd7c10..37fb21f4eb27 100644 --- a/optional-skills/security/1password/SKILL.md +++ b/optional-skills/security/1password/SKILL.md @@ -138,8 +138,8 @@ echo "db_password: {{ op://app-prod/db/password }}" | op inject ### Run a command with secret env var ```bash -export OPENAI_API_KEY="op://app-prod/openai/api key" -op run -- sh -c '[ -n "$OPENAI_API_KEY" ] && echo "OPENAI_API_KEY is set" || echo "OPENAI_API_KEY missing"' +export DB_PASSWORD="op://app-prod/db/password" +op run -- sh -c '[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD is set" || echo "DB_PASSWORD missing"' ``` ## Guardrails